@woodfell/utils
v0.1.3
Published
Reusable TypeScript utilities shared across Woodfell projects.
Readme
@woodfell/utils
Reusable, runtime-neutral TypeScript utilities for Woodfell projects. The root entrypoint is intentionally small; the Supabase test double is available from its own subpath:
import { createSchema, init } from "@woodfell/utils/supabase-mock";This package is published to npm. Pin the version in production repositories. For Supabase Edge Functions, use Deno's npm specifier instead:
import { createSchema, init } from "npm:@woodfell/utils@<version>/supabase-mock";supabase-mock
supabase-mock is an in-memory, schema-aware test double for the database
portion of an injected SupabaseClient. It is for unit tests that need to
exercise real PostgREST-style query chains without starting Supabase.
It reads the consumer's migrations to learn table, column, and function names.
Tests then seed rows and run the same .from(...).select()... code their
service uses in production. This catches misspelled tables and columns instead
of accepting arbitrary fixtures or query strings.
It is not an emulator for Postgres or the Supabase platform. Use the local Supabase stack / integration tests for database constraints, generated values, triggers, RLS and auth, SQL functions, Storage, Realtime, or any query shape the mock does not support.
Install and initialise
Projects that import this subpath need @supabase/supabase-js available. It is
an optional peer dependency of this package so projects that only use the root
entrypoint do not need it.
Parse the migrations once in a Vitest setupFiles module, then create a fresh
mock in every test. setupFiles runs in each worker; do not put
createSchema() in Vitest globalSetup, which runs in a separate process.
// test/setup-supabase-mock.ts
import { createSchema } from "@woodfell/utils/supabase-mock";
await createSchema("supabase/migrations");// permits-service.test.ts
import { beforeEach, expect, test } from "vitest";
import { init } from "@woodfell/utils/supabase-mock";
let db: ReturnType<typeof init>;
beforeEach(() => {
db = init(); // an empty store, bound to the migrations schema
db.table("permits").data([
{ id: 1, ref: "TE0001", status: "active", site_id: 10 },
]);
});
test("finds an active permit", async () => {
const { data, error } = await db
.from("permits")
.select("id, ref")
.eq("status", "active")
.single();
expect(error).toBeNull();
expect(data).toEqual({ id: 1, ref: "TE0001" });
});init() throws if createSchema() has not run. The parsed schema is shared in
the module, but each init() call gets independent row state. If one process
needs different migration folders, build schemas explicitly with
schemaFromMigrations() and pass each to createSupabaseMock() instead.
Seed data and inspect writes
All setup methods validate table and column names against the migrations and clone their inputs. This means mutating a fixture later cannot affect the mock.
const db = init();
db.seed({
permits: [{ id: 1, ref: "TE0001", status: "active" }],
});
db.table("permits").data([{ id: 2, ref: "TE0002", status: "active" }]);
// `data` replaces all existing rows for that table.
db.table("permits").insert({ id: 3, ref: "TE0003", status: "expired" });
// `insert` appends setup rows.
expect(db.rowsFor("permits")).toHaveLength(2); // returns cloned current rowsSeeded rows use Row (Record<string, unknown>). Supabase-generated row
type aliases work directly. A handwritten interface needs an index
signature or a cast at the seed call because TypeScript does not treat it as a
Record<string, unknown>.
Supported query contract
The mock evaluates these database query operations against its in-memory rows:
- Reads and writes:
select,insert,update,delete,single, andmaybeSingle. A write changes the store once per query builder; chain.select()to receive the affected rows, as with Supabase. - Filters:
eq,neq,gt,gte,lt,lte,ilike,in,is,not, and the supported flat form ofor. - Result shaping: simple comma-separated projections,
order,limit,range, andselect(..., { count, head })for reads. Counts represent the filtered total before range or limit.
Null comparisons follow Postgres three-valued logic: use .is("column", null)
to find nulls; .eq("column", null) matches no rows. Ascending order puts
nulls last and descending order puts them first, unless nullsFirst is set.
The mock returns cloned results and PostgREST-shaped query errors, so service
code can use its normal { data, error } handling.
| Situation | Behaviour |
| --- | --- |
| Query unknown table | Resolves with PGRST205 |
| Read/filter/order/projection of unknown column | Resolves with 42703 |
| Insert/update unknown column | Resolves with PGRST204 and does not mutate the store |
| Seed unknown table or column | Throws immediately: the fixture is invalid |
| single() with zero or multiple visible rows | Resolves with PGRST116 |
Schemas and RPCs
Unqualified queries use the public schema. A migration object is identified
by schema.name, so same-named tables in two schemas have separate rows. Only
public is exposed to the simulated Data API by default. Add every extra
PostgREST-exposed schema when initialising the mock, and use .schema() for
both queries and mock setup:
const db = init({ exposedSchemas: ["public", "private"] });
db.schema("private").table("secrets").data([{ id: 1, value: "test" }]);
const { data } = await db.schema("private").from("secrets").select("*");A query or RPC against a schema outside this allowlist resolves with PGRST106.
Setup is not exposure-gated: a non-public table may still be seeded through its
schema facade.
RPCs cannot be calculated from rows, so they are the one deliberate stub seam. The function must exist in migrations, and every call must be registered:
db.onRpc("expire_old_permits").thenReturn({ expired: 4 });
// or: db.onRpc("expire_old_permits").thenError({ message: "unavailable", code: "XX000" });
const { data, error } = await db.rpc("expire_old_permits");An unstubbed RPC rejects, making an omitted test setup obvious. There is no query-stubbing API: express table-query scenarios with seeded data instead.
Deliberate limits
Unsupported behaviour fails loudly rather than returning a plausible but wrong answer. In particular, do not use this mock for:
- embedded-resource selects / joins, JSON-path columns, nested
orgroups, oror(... in.(...)); - write
countoptions, unsupported operators, or unrecognised method options; - database constraints, defaults, generated values, foreign keys, triggers, RLS, authentication, or executing SQL/RPC function bodies.
For those cases, write an integration test against a local Supabase instance.
Public API
@woodfell/utils/supabase-mock exports:
createSchema(migrationsFolder)andinit(options?)— the standard filesystem-backed test harness.schemaFromMigrations(migrations)andcreateSupabaseMock(schema, options?)— use these when migration SQL is already in memory or multiple schemas are needed in one process.SupabaseMockplusRpcStub,TableSetup,Schema,TableSchema,ColumnSchema,Row,QueryResult,SingleResult,MockError, andMigrationSourcetypes.
The returned mock is assignable to SupabaseClient, so it can be passed to
application code that accepts an injected real Supabase client. The setup
helpers (seed, table, rowsFor, onRpc) are mock-only conveniences.
Development
From the repository root:
npm ci
npm run typecheck
npm run test
npm run buildThe package's public API is its root barrel and declared export subpaths. Do
not import files below src/ directly from consumers.
