@marktiderman/genesis-core 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -389,6 +389,61 @@ leave; asks with `window.confirm` otherwise). For other state,
389
389
  `confirmLeave` and `beforeunload` handling. Neither depends on a router; see
390
390
  genesis-ui's SKILL.md "Unsaved changes" for React Router and TanStack Router.
391
391
 
392
+ ## Test your data contract
393
+
394
+ `@marktiderman/genesis-core/testing` checks your own provider keeps the data rules, against your
395
+ real schema (a local Supabase in CI). Its pgTAP twin is genesis-supabase's `genesis_testing`; both
396
+ use the same fixture people (`FIXTURE_USERS`: owner, staff, client, stranger), so seed them once
397
+ with `select genesis_testing.create_users();`.
398
+
399
+ Each check passes only on evidence and throws a `ContractError` otherwise, so it works in vitest,
400
+ jest or node:test. A wrong resource, a missing row or a typo fails instead of passing. The control and system
401
+ writes are real (and `expectServerOwned` puts its value back): run the checks on test data.
402
+
403
+ A refusal must read as plain words, so a provider that passes the database's raw text through (an
404
+ RLS insert refusal says `new row violates row-level security policy …`) fails `expectRefused`. Map
405
+ it to a plain message in the provider; that failure is the kit doing its job.
406
+
407
+ ```ts
408
+ import { createClient } from "@supabase/supabase-js";
409
+ import { createSupabaseProvider } from "@marktiderman/genesis-core/provider";
410
+ import {
411
+ expectBinned, expectNeverShows, expectRefused, expectServerOwned, fixtureAccessToken,
412
+ } from "@marktiderman/genesis-core/testing";
413
+
414
+ // The local stack's legacy JWT secret (`supabase status`); never a production one.
415
+ const providerFor = async (who: "owner" | "client" | "stranger") => {
416
+ const token = await fixtureAccessToken(who, { secret: process.env.SUPABASE_JWT_SECRET! });
417
+ const client = createClient(url, anonKey, { global: { headers: { Authorization: `Bearer ${token}` } } });
418
+ return createSupabaseProvider(client, { binTables: ["notes"] });
419
+ };
420
+
421
+ const client = await providerFor("client");
422
+ const owner = await providerFor("owner");
423
+ const stranger = await providerFor("stranger");
424
+ const system = createSupabaseProvider(createClient(url, serviceRoleKey)); // the server's own writes
425
+
426
+ // A write the client may not make is refused, in plain words. When RLS hides the row (it reaches
427
+ // no row), the control runs the same write as someone who may make it and must change a row.
428
+ await expectRefused((p) => p.update("tasks", { id, data: { title: "x" } }), { as: stranger, control: owner });
429
+ // A column only the server sets stays as it was (the system proves it can change it first).
430
+ await expectServerOwned({ as: client, system, resource: "tasks", id, field: "status", value: "done" });
431
+ // A private value never shows, reports included: read the list around it, not the row by id.
432
+ await expectNeverShows(client.getList("group_progress", {}), "private-marker-7");
433
+ // A delete on a bin table keeps the row.
434
+ await expectBinned({ as: client, resource: "notes", id, inBin: () => adminSeesDeleted("notes", id) });
435
+ ```
436
+
437
+ | Check | Passes when |
438
+ | --- | --- |
439
+ | `expectRefused(write, { as?, control? })` | it's refused (42501, a guard's P0001, or `forbidden` / `unauthenticated`) in plain words; reaching no row counts only when `control` makes the same write and changes a row |
440
+ | `expectAllowed(write)` | it resolves |
441
+ | `expectServerOwned({ as, system, … })` | `system` can change the value (then puts it back), and it's unchanged after `as` tries; refused or put back both count |
442
+ | `expectNeverShows(read, marker)` | the marker appears nowhere in the result (raw or JSON-escaped), or the read is refused; a read that found nothing fails |
443
+ | `expectSees(read, n)` | the read returns exactly `n` rows |
444
+ | `expectBinned({ as, resource, id, inBin })` | `as` sees the row, deletes it, can't read it back, and `inBin()` confirms it's still stored |
445
+ | `expectPlainMessage(error)` | the message has no raw database text or error codes |
446
+
392
447
  ## Autosave a draft
393
448
 
394
449
  `useDraft` saves one row as someone edits it: an answer to a questionnaire, a
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { L as ListParams, a as Identifier } from './types-B9DDfvWa.js';
2
2
  export { A as ArrayFilter, B as BaseRecord, b as BulkChange, c as BulkResult, C as CreateParams, d as CreateResult, D as DataProvider, e as DeleteParams, f as DeleteResult, F as FilterParam, G as GetManyParams, g as GetManyResult, h as GetOneParams, i as GetOneResult, H as HistoryParams, j as ImportParams, I as InvalidationKey, k as ListResult, N as NullFilter, P as PaginationParam, R as RangeFilter, l as ResourceChangeEvent, m as RowFailure, S as ScalarFilter, n as SortParam, o as SubscribeParams, U as UpdateParams, p as UpdateResult, W as WriteEvent } from './types-B9DDfvWa.js';
3
- export { D as DataReadError, a as DataReadErrorOptions, b as DataWriteError, c as DataWriteErrorOptions, R as ReadOutcome, W as WriteOutcome, i as isDataReadError, d as isDataWriteError, r as readFailureMessage } from './read-outcome-B6nACXZ7.js';
3
+ export { D as DataWriteError, a as DataWriteErrorOptions, W as WriteOutcome, i as isDataWriteError } from './write-outcome-B2fE29yZ.js';
4
4
  export { CallRpcOptions, CreateOrAdoptOptions, DEFAULT_RPC_MESSAGES, DataProviderRoot, DataProviderRootProps, GenesisProvider, GenesisProviderProps, MockProviderOptions, PostgrestFieldErrors, RPC_FAILED, RpcClient, SupabaseClient, SupabaseClientInput, SupabaseProviderOptions, SupabaseRealtimeChannel, SupabaseRealtimePayload, SupabaseResourceConfig, SupabaseWriteContext, callRpc, createMockProvider, createOrAdopt, createSupabaseProvider, fieldErrorsFromPostgrest, readOutcomeFromCode, toDataWriteError, useDataProvider, useStorage, writeOutcomeFromCode } from './provider/index.js';
5
+ export { D as DataReadError, a as DataReadErrorOptions, R as ReadOutcome, i as isDataReadError, r as readFailureMessage } from './read-outcome-BnboMYOV.js';
5
6
  export { S as StorageAdapter } from './types-DKcxHXxq.js';
6
7
  export { createLocalStorageAdapter, createMemoryStorageAdapter, createNoopStorageAdapter } from './storage/index.js';
7
8
  export { UseResourceOptions, UseResourceReturn, useResource } from './hooks/use-resource.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { DEFAULT_RPC_MESSAGES, RPC_FAILED, callRpc, createOrAdopt, createSupabaseProvider, fieldErrorsFromPostgrest, readOutcomeFromCode, toDataWriteError, writeOutcomeFromCode } from './chunk-EF5BKT4B.js';
2
+ import './chunk-XCX2TRBL.js';
2
3
  export { decodeRecordKey, encodeRecordKey, keyFields } from './chunk-KV2HW4G4.js';
3
4
  export { DataReadError, isDataReadError, readFailureMessage } from './chunk-HUNGCTRA.js';
4
- import './chunk-XCX2TRBL.js';
5
5
  import './chunk-65SXNHWE.js';
6
6
  export { ViewerScope, clearViewerCache } from './chunk-5ORRAXIV.js';
7
7
  export { useResource } from './chunk-NMFABW4Y.js';
@@ -1,7 +1,9 @@
1
1
  import { D as DataProvider, a as Identifier, B as BaseRecord, I as InvalidationKey, W as WriteEvent } from '../types-B9DDfvWa.js';
2
2
  export { A as ArrayFilter, b as BulkChange, c as BulkResult, C as CreateParams, d as CreateResult, e as DeleteParams, f as DeleteResult, F as FilterParam, G as GetManyParams, g as GetManyResult, h as GetOneParams, i as GetOneResult, H as HistoryParams, j as ImportParams, L as ListParams, k as ListResult, N as NullFilter, P as PaginationParam, R as RangeFilter, l as ResourceChangeEvent, m as RowFailure, S as ScalarFilter, n as SortParam, o as SubscribeParams, U as UpdateParams, p as UpdateResult } from '../types-B9DDfvWa.js';
3
- import { D as DataReadError, R as ReadOutcome, b as DataWriteError, W as WriteOutcome } from '../read-outcome-B6nACXZ7.js';
4
- export { a as DataReadErrorOptions, c as DataWriteErrorOptions, i as isDataReadError, d as isDataWriteError, r as readFailureMessage } from '../read-outcome-B6nACXZ7.js';
3
+ import { D as DataWriteError, W as WriteOutcome } from '../write-outcome-B2fE29yZ.js';
4
+ export { a as DataWriteErrorOptions, i as isDataWriteError } from '../write-outcome-B2fE29yZ.js';
5
+ import { D as DataReadError, R as ReadOutcome } from '../read-outcome-BnboMYOV.js';
6
+ export { a as DataReadErrorOptions, i as isDataReadError, r as readFailureMessage } from '../read-outcome-BnboMYOV.js';
5
7
  import * as react from 'react';
6
8
  import { ReactNode } from 'react';
7
9
  import { QueryClient } from '@tanstack/react-query';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * - `unauthenticated`: no signed-in user, or the session expired.
3
+ * - `forbidden`: this user may not read these records.
4
+ * - `gone`: the one record asked for doesn't exist, or isn't visible.
5
+ * - `invalid`: the request itself was wrong (an unknown column, a bad value).
6
+ * - `error`: anything else.
7
+ */
8
+ type ReadOutcome = "unauthenticated" | "forbidden" | "gone" | "invalid" | "error";
9
+ interface DataReadErrorOptions {
10
+ /** The store's own error code (a Postgres SQLSTATE or PostgREST code). */
11
+ code?: string;
12
+ /** The underlying error, with its raw text, kept for logging. */
13
+ cause?: unknown;
14
+ }
15
+ /** Thrown by a provider when a read failed. Its `message` is safe to show. */
16
+ declare class DataReadError extends Error {
17
+ readonly outcome: ReadOutcome;
18
+ /** The store's own error code, when it gave one. */
19
+ readonly code?: string;
20
+ constructor(outcome: ReadOutcome, message: string, options?: DataReadErrorOptions);
21
+ }
22
+ /**
23
+ * `instanceof` fails when two copies of this package are installed, so check
24
+ * the shape instead.
25
+ */
26
+ declare function isDataReadError(error: unknown): error is DataReadError;
27
+ /** The plain sentence for a failed read of `noun` records (one, or a list). */
28
+ declare function readFailureMessage(outcome: ReadOutcome, noun: string, one: boolean, plural?: string): string;
29
+
30
+ export { DataReadError as D, type ReadOutcome as R, type DataReadErrorOptions as a, isDataReadError as i, readFailureMessage as r };
@@ -1,7 +1,8 @@
1
1
  import { e as ResourceContract, D as DataRow } from '../types-C7FJtFXV.js';
2
2
  export { A as AuditEntry, F as FieldPermissions, a as FieldReference, b as FieldSchema, c as FilterOperator, P as ProviderCapabilities, R as RecordComment, d as ResourceAccess, f as ResourceRelation, g as ResourceSchema, h as ResourceSummary, i as RowAction, S as StorageType, V as VersionField } from '../types-C7FJtFXV.js';
3
3
  import { a as Identifier } from '../types-B9DDfvWa.js';
4
- export { D as DataReadError, a as DataReadErrorOptions, b as DataWriteError, c as DataWriteErrorOptions, R as ReadOutcome, W as WriteOutcome, i as isDataReadError, d as isDataWriteError, r as readFailureMessage } from '../read-outcome-B6nACXZ7.js';
4
+ export { D as DataWriteError, a as DataWriteErrorOptions, W as WriteOutcome, i as isDataWriteError } from '../write-outcome-B2fE29yZ.js';
5
+ export { D as DataReadError, a as DataReadErrorOptions, R as ReadOutcome, i as isDataReadError, r as readFailureMessage } from '../read-outcome-BnboMYOV.js';
5
6
  export { FieldType as WidgetHint } from '../form-types.js';
6
7
 
7
8
  /** The key of a contract as a list, whichever form `key` was declared in. */
@@ -0,0 +1,102 @@
1
+ import { D as DataProvider, a as Identifier } from '../types-B9DDfvWa.js';
2
+ import { D as DataWriteError } from '../write-outcome-B2fE29yZ.js';
3
+ import '../types-C7FJtFXV.js';
4
+ import '../form-types.js';
5
+
6
+ /** The shared fixture logins, the same ids `genesis_testing.user_id()` uses. */
7
+ declare const FIXTURE_USERS: {
8
+ readonly owner: "00000000-0000-4000-8000-000000000001";
9
+ readonly staff: "00000000-0000-4000-8000-000000000002";
10
+ readonly client: "00000000-0000-4000-8000-000000000003";
11
+ readonly stranger: "00000000-0000-4000-8000-000000000004";
12
+ };
13
+ type FixtureUser = keyof typeof FIXTURE_USERS;
14
+ /** A broken contract: the message says what happened instead. */
15
+ declare class ContractError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ /**
19
+ * A signed access token for a fixture person (or any user id), for a test
20
+ * database's legacy JWT secret. Hand it to the client as
21
+ * `Authorization: Bearer <token>`; `auth.uid()` then reads that user. Test
22
+ * databases only: never give it a production secret.
23
+ */
24
+ declare function fixtureAccessToken(user: FixtureUser | string, options: {
25
+ secret: string;
26
+ expiresInSeconds?: number;
27
+ role?: string;
28
+ }): Promise<string>;
29
+ /** The error's message is plain words: no raw database text or error codes. */
30
+ declare function expectPlainMessage(error: unknown): void;
31
+ /**
32
+ * Whether a failed write is a refusal: a refusal code (42501, P0001), or the
33
+ * provider's own `forbidden` (the row is still visible but unchanged) or
34
+ * `unauthenticated`. `gone` is not: it also means the id matched nothing.
35
+ */
36
+ declare function isRefusal(error: unknown): error is DataWriteError;
37
+ interface ExpectRefusedOptions {
38
+ /** With a write function: who tries it (and must be refused). */
39
+ as?: DataProvider;
40
+ /**
41
+ * Proof a write that reached no row (`gone`) was refused, not mistyped.
42
+ * With a write function: a provider for someone who may make it, which runs
43
+ * the same function. With a promise: a function making the same write as
44
+ * that someone. Either way it must resolve with the row it changed. Its
45
+ * write is real: run the check on test data.
46
+ */
47
+ control?: DataProvider | (() => Promise<unknown>);
48
+ }
49
+ /**
50
+ * The write is refused, in plain words: a refusal code (42501, P0001) or the
51
+ * provider's `forbidden` / `unauthenticated`. A write that reaches no row
52
+ * (`gone`) counts only with a `control` that makes the same write and changes
53
+ * a row, so a typo'd id or resource can't pass. Pass the write as a function
54
+ * of a provider (with `as` and a control provider) to run exactly the same
55
+ * write both ways. Returns the refusal for more checks.
56
+ *
57
+ * ```ts
58
+ * await expectRefused((p) => p.update("tasks", { id, data: { title: "x" } }), { as: stranger, control: owner });
59
+ * ```
60
+ */
61
+ declare function expectRefused(write: Promise<unknown> | ((provider: DataProvider) => Promise<unknown>), options?: ExpectRefusedOptions): Promise<DataWriteError>;
62
+ /** The write succeeds. Returns what it resolved to. */
63
+ declare function expectAllowed<T>(write: Promise<T>): Promise<T>;
64
+ /**
65
+ * A client can't change a column only the server sets. First `system` (the
66
+ * service role, or whoever the server writes as) sets `field` to `value` and
67
+ * must change the stored value, then puts it back: that proves the row exists
68
+ * and the value can change at all. Then `as` tries the same update, and the
69
+ * stored value must stay as it was, whether the update was refused, reached
70
+ * no row, or a trigger put the value back. Any other error fails.
71
+ */
72
+ declare function expectServerOwned(args: {
73
+ as: DataProvider;
74
+ system: DataProvider;
75
+ resource: string;
76
+ id: Identifier;
77
+ field: string;
78
+ value: unknown;
79
+ }): Promise<void>;
80
+ /**
81
+ * `marker` (a value only a private row holds) appears nowhere in what the read
82
+ * returns: use a list or report read that returns the rows around the private
83
+ * one. A refused read (42501, or `forbidden` / `unauthenticated`) shows
84
+ * nothing and passes; any other failure fails, `gone` included, so a wrong id
85
+ * or a broken query can't pass.
86
+ */
87
+ declare function expectNeverShows(read: Promise<unknown>, marker: string): Promise<void>;
88
+ /** The read returns exactly `expected` rows (a list result, a getMany result or an array). */
89
+ declare function expectSees(read: Promise<unknown>, expected: number): Promise<void>;
90
+ /**
91
+ * A delete on a recycle-bin table moves the row to the bin: `as` deletes it,
92
+ * can no longer read it (gone or forbidden), and `inBin` (a check with the
93
+ * service role or a bin query) confirms the row is still stored.
94
+ */
95
+ declare function expectBinned(args: {
96
+ as: DataProvider;
97
+ resource: string;
98
+ id: Identifier;
99
+ inBin: () => Promise<boolean>;
100
+ }): Promise<void>;
101
+
102
+ export { ContractError, type ExpectRefusedOptions, FIXTURE_USERS, type FixtureUser, expectAllowed, expectBinned, expectNeverShows, expectPlainMessage, expectRefused, expectSees, expectServerOwned, fixtureAccessToken, isRefusal };
@@ -0,0 +1,210 @@
1
+ import { isDataReadError } from '../chunk-HUNGCTRA.js';
2
+ import { isDataWriteError } from '../chunk-LG6TUBVM.js';
3
+
4
+ // src/testing/index.ts
5
+ var FIXTURE_USERS = {
6
+ owner: "00000000-0000-4000-8000-000000000001",
7
+ staff: "00000000-0000-4000-8000-000000000002",
8
+ client: "00000000-0000-4000-8000-000000000003",
9
+ stranger: "00000000-0000-4000-8000-000000000004"
10
+ };
11
+ var ContractError = class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "ContractError";
15
+ }
16
+ };
17
+ async function fixtureAccessToken(user, options) {
18
+ const sub = Object.hasOwn(FIXTURE_USERS, user) ? FIXTURE_USERS[user] : user;
19
+ const now = Math.floor(Date.now() / 1e3);
20
+ const role = options.role ?? "authenticated";
21
+ const claims = { sub, role, aud: role, iat: now, exp: now + (options.expiresInSeconds ?? 3600) };
22
+ const encode = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23
+ const text = (value) => encode(new TextEncoder().encode(JSON.stringify(value)));
24
+ const body = `${text({ alg: "HS256", typ: "JWT" })}.${text(claims)}`;
25
+ const key = await crypto.subtle.importKey(
26
+ "raw",
27
+ new TextEncoder().encode(options.secret),
28
+ { name: "HMAC", hash: "SHA-256" },
29
+ false,
30
+ ["sign"]
31
+ );
32
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
33
+ return `${body}.${encode(new Uint8Array(signature))}`;
34
+ }
35
+ var RAW_TEXT = /violates|constraint|duplicate key|row-level security|permission denied for|relation "|syntax error|null value in column|\b(?:42501|42P01|42703|23505|23503|23502|23514|22P02|P0001|PGRST[0-9]{3})\b/i;
36
+ function expectPlainMessage(error) {
37
+ const message = error instanceof Error ? error.message : error && typeof error === "object" && typeof error.message === "string" ? error.message : typeof error === "string" ? error : "";
38
+ if (!message.trim()) throw new ContractError("the error has no message to show");
39
+ if (RAW_TEXT.test(message)) throw new ContractError(`the message shows raw database text: ${message}`);
40
+ }
41
+ var REFUSAL_CODES = /* @__PURE__ */ new Set(["42501", "P0001"]);
42
+ function isRefusal(error) {
43
+ if (!isDataWriteError(error)) return false;
44
+ if (error.code !== void 0 && REFUSAL_CODES.has(error.code)) return true;
45
+ return error.outcome === "forbidden" || error.outcome === "unauthenticated";
46
+ }
47
+ var describeError = (error) => isDataWriteError(error) || isDataReadError(error) ? `${error.outcome}${error.code ? ` (${error.code})` : ""}: ${error.message}` : String(error);
48
+ function changedARow(result) {
49
+ if (!result || typeof result !== "object") return false;
50
+ const r = result;
51
+ if (r.error !== void 0 && r.error !== null) return false;
52
+ if (Array.isArray(r.data)) return r.data.length > 0;
53
+ return r.data !== null && typeof r.data === "object";
54
+ }
55
+ async function expectRefused(write, options = {}) {
56
+ if (typeof write !== "function") void write.catch(() => void 0);
57
+ let attempt;
58
+ let control;
59
+ if (typeof write === "function") {
60
+ if (!options.as) throw new ContractError("pass `as`: the provider that should be refused");
61
+ const by = options.control;
62
+ if (by !== void 0 && typeof by === "function") {
63
+ throw new ContractError("with a write function, `control` is a provider that runs the same write");
64
+ }
65
+ attempt = write(options.as);
66
+ control = by ? () => write(by) : void 0;
67
+ } else {
68
+ attempt = write;
69
+ if (options.control !== void 0 && typeof options.control !== "function") {
70
+ throw new ContractError("with a write promise, `control` is a function making the same write");
71
+ }
72
+ control = options.control;
73
+ }
74
+ let result;
75
+ try {
76
+ result = await attempt;
77
+ } catch (error) {
78
+ if (isRefusal(error)) {
79
+ expectPlainMessage(error);
80
+ return error;
81
+ }
82
+ if (isDataWriteError(error) && error.outcome === "gone") {
83
+ if (!control) {
84
+ throw new ContractError(
85
+ `it reached no row (${error.message}); pass a control that can make the same write, or it could be a wrong id`
86
+ );
87
+ }
88
+ let controlResult;
89
+ try {
90
+ controlResult = await control();
91
+ } catch (controlError) {
92
+ throw new ContractError(`the control failed too (${describeError(controlError)}): nothing was refused`);
93
+ }
94
+ if (!changedARow(controlResult)) {
95
+ throw new ContractError(
96
+ `the control changed no row (${JSON.stringify(controlResult)?.slice(0, 200)}): nothing was refused`
97
+ );
98
+ }
99
+ expectPlainMessage(error);
100
+ return error;
101
+ }
102
+ throw new ContractError(`it failed, but not as a refusal: ${describeError(error)}`);
103
+ }
104
+ throw new ContractError(`it was allowed: ${JSON.stringify(result)?.slice(0, 300)}`);
105
+ }
106
+ async function expectAllowed(write) {
107
+ try {
108
+ return await write;
109
+ } catch (error) {
110
+ throw new ContractError(`it was refused: ${describeError(error)}`);
111
+ }
112
+ }
113
+ var stable = (value) => JSON.stringify(
114
+ value,
115
+ (_key, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v
116
+ );
117
+ async function expectServerOwned(args) {
118
+ const { as, system, resource, id, field, value } = args;
119
+ const read = async () => {
120
+ const row = (await system.getOne(resource, { id })).data;
121
+ if (!row || !(field in row)) throw new ContractError(`${resource} ${String(id)} has no field ${field}`);
122
+ return row[field];
123
+ };
124
+ const before = await read();
125
+ if (stable(before) === stable(value)) {
126
+ throw new ContractError(`${field} is already ${stable(value)}: try a different value`);
127
+ }
128
+ const set = async (to, what) => {
129
+ try {
130
+ await system.update(resource, { id, data: { [field]: to } });
131
+ } catch (error) {
132
+ throw new ContractError(`the system couldn't ${what} ${field} (${describeError(error)}): nothing was tested`);
133
+ }
134
+ };
135
+ await set(value, "set");
136
+ const changed = await read();
137
+ await set(before, "put back");
138
+ if (stable(changed) === stable(before)) {
139
+ throw new ContractError(`the system couldn't change ${field} either: nothing was tested`);
140
+ }
141
+ if (stable(await read()) !== stable(before)) {
142
+ throw new ContractError(`the system couldn't put ${field} back to ${stable(before)}`);
143
+ }
144
+ try {
145
+ await as.update(resource, { id, data: { [field]: value } });
146
+ } catch (error) {
147
+ if (!isRefusal(error) && !(isDataWriteError(error) && error.outcome === "gone")) {
148
+ throw new ContractError(`setting ${field} failed, but not as a refusal: ${describeError(error)}`);
149
+ }
150
+ }
151
+ const after = await read();
152
+ if (stable(after) !== stable(before)) {
153
+ await system.update(resource, { id, data: { [field]: before } }).catch(() => void 0);
154
+ throw new ContractError(`${resource}.${field} changed from ${stable(before)} to ${stable(after)}`);
155
+ }
156
+ }
157
+ async function expectNeverShows(read, marker) {
158
+ if (!marker) throw new ContractError("give a marker to look for");
159
+ let result;
160
+ try {
161
+ result = await read;
162
+ } catch (error) {
163
+ const refused = (isDataReadError(error) || isDataWriteError(error)) && (error.code !== void 0 && REFUSAL_CODES.has(error.code) || error.outcome === "forbidden" || error.outcome === "unauthenticated");
164
+ if (refused) return;
165
+ throw new ContractError(`the read failed: ${describeError(error)}`);
166
+ }
167
+ if (result === void 0) throw new ContractError("the read returned nothing to look through");
168
+ if (result && typeof result === "object" && "error" in result && result.error != null) {
169
+ throw new ContractError(`the read failed: ${JSON.stringify(result.error)?.slice(0, 200)}`);
170
+ }
171
+ const text = JSON.stringify(result) ?? "";
172
+ const escaped = JSON.stringify(marker).slice(1, -1);
173
+ if (text.includes(marker) || text.includes(escaped)) {
174
+ throw new ContractError(`"${marker}" appears in: ${text.slice(0, 300)}`);
175
+ }
176
+ }
177
+ async function expectSees(read, expected) {
178
+ let result;
179
+ try {
180
+ result = await read;
181
+ } catch (error) {
182
+ throw new ContractError(`the read failed: ${error instanceof Error ? error.message : String(error)}`);
183
+ }
184
+ const rows = Array.isArray(result) ? result : result && typeof result === "object" && Array.isArray(result.data) ? result.data : void 0;
185
+ if (!rows) throw new ContractError(`the read returned no rows to count: ${JSON.stringify(result)?.slice(0, 300)}`);
186
+ if (rows.length !== expected) throw new ContractError(`saw ${rows.length} row(s), expected ${expected}`);
187
+ }
188
+ async function expectBinned(args) {
189
+ const { as, resource, id, inBin } = args;
190
+ try {
191
+ await as.getOne(resource, { id });
192
+ } catch (error) {
193
+ throw new ContractError(`${resource} ${String(id)} isn't visible before the delete: ${describeError(error)}`);
194
+ }
195
+ await expectAllowed(as.deleteOne(resource, { id }));
196
+ try {
197
+ await as.getOne(resource, { id });
198
+ } catch (error) {
199
+ if (!isDataReadError(error) || error.outcome !== "gone" && error.outcome !== "forbidden") {
200
+ throw new ContractError(`reading it back failed: ${describeError(error)}`);
201
+ }
202
+ if (!await inBin()) throw new ContractError(`${resource} ${String(id)} was deleted for good, not binned`);
203
+ return;
204
+ }
205
+ throw new ContractError(`${resource} ${String(id)} is still visible after the delete`);
206
+ }
207
+
208
+ export { ContractError, FIXTURE_USERS, expectAllowed, expectBinned, expectNeverShows, expectPlainMessage, expectRefused, expectSees, expectServerOwned, fixtureAccessToken, isRefusal };
209
+ //# sourceMappingURL=index.js.map
210
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/testing/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Contract test kit (README \"Test your data contract\")\n//\n// Checks an app's own provider keeps the Genesis data rules, run against the\n// app's real schema (a local Supabase in CI): a write the user may not make is\n// refused in plain words, a column only the server sets stays as it was, a\n// private value never shows, a binned delete keeps the row. The pgTAP half is\n// genesis-supabase's `genesis_testing`; both use the same fixture people.\n//\n// Every check passes only on evidence and throws a ContractError otherwise, so\n// it works in vitest, jest or node:test alike. A wrong resource, a missing row\n// or a typo fails the check instead of passing it.\n// ---------------------------------------------------------------------------\n\nimport type { BaseRecord, DataProvider, Identifier } from \"../provider/types\";\nimport { isDataReadError } from \"../schema/read-outcome\";\nimport { isDataWriteError, type DataWriteError } from \"../schema/write-outcome\";\n\n/** The shared fixture logins, the same ids `genesis_testing.user_id()` uses. */\nexport const FIXTURE_USERS = {\n owner: \"00000000-0000-4000-8000-000000000001\",\n staff: \"00000000-0000-4000-8000-000000000002\",\n client: \"00000000-0000-4000-8000-000000000003\",\n stranger: \"00000000-0000-4000-8000-000000000004\",\n} as const;\n\nexport type FixtureUser = keyof typeof FIXTURE_USERS;\n\n/** A broken contract: the message says what happened instead. */\nexport class ContractError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ContractError\";\n }\n}\n\n/**\n * A signed access token for a fixture person (or any user id), for a test\n * database's legacy JWT secret. Hand it to the client as\n * `Authorization: Bearer <token>`; `auth.uid()` then reads that user. Test\n * databases only: never give it a production secret.\n */\nexport async function fixtureAccessToken(\n user: FixtureUser | string,\n options: { secret: string; expiresInSeconds?: number; role?: string },\n): Promise<string> {\n const sub = Object.hasOwn(FIXTURE_USERS, user) ? FIXTURE_USERS[user as FixtureUser] : user;\n const now = Math.floor(Date.now() / 1000);\n const role = options.role ?? \"authenticated\";\n const claims = { sub, role, aud: role, iat: now, exp: now + (options.expiresInSeconds ?? 3600) };\n const encode = (bytes: Uint8Array) =>\n btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n const text = (value: unknown) => encode(new TextEncoder().encode(JSON.stringify(value)));\n const body = `${text({ alg: \"HS256\", typ: \"JWT\" })}.${text(claims)}`;\n const key = await crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(options.secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const signature = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(body));\n return `${body}.${encode(new Uint8Array(signature))}`;\n}\n\n/** Raw database text that must never reach a person. */\nconst RAW_TEXT =\n /violates|constraint|duplicate key|row-level security|permission denied for|relation \"|syntax error|null value in column|\\b(?:42501|42P01|42703|23505|23503|23502|23514|22P02|P0001|PGRST[0-9]{3})\\b/i;\n\n/** The error's message is plain words: no raw database text or error codes. */\nexport function expectPlainMessage(error: unknown): void {\n const message =\n error instanceof Error\n ? error.message\n : error && typeof error === \"object\" && typeof (error as { message?: unknown }).message === \"string\"\n ? (error as { message: string }).message\n : typeof error === \"string\"\n ? error\n : \"\";\n if (!message.trim()) throw new ContractError(\"the error has no message to show\");\n if (RAW_TEXT.test(message)) throw new ContractError(`the message shows raw database text: ${message}`);\n}\n\n/** Codes that are refusals: insufficient privilege (RLS, grants) and a guard's `raise exception`. */\nconst REFUSAL_CODES = new Set([\"42501\", \"P0001\"]);\n\n/**\n * Whether a failed write is a refusal: a refusal code (42501, P0001), or the\n * provider's own `forbidden` (the row is still visible but unchanged) or\n * `unauthenticated`. `gone` is not: it also means the id matched nothing.\n */\nexport function isRefusal(error: unknown): error is DataWriteError {\n if (!isDataWriteError(error)) return false;\n if (error.code !== undefined && REFUSAL_CODES.has(error.code)) return true;\n return error.outcome === \"forbidden\" || error.outcome === \"unauthenticated\";\n}\n\nconst describeError = (error: unknown) =>\n isDataWriteError(error) || isDataReadError(error)\n ? `${error.outcome}${error.code ? ` (${error.code})` : \"\"}: ${error.message}`\n : String(error);\n\n/** Whether a write's result shows a row it changed: `{ data: row }` or a non-empty `data` list, with no `error`. */\nfunction changedARow(result: unknown): boolean {\n if (!result || typeof result !== \"object\") return false;\n const r = result as { data?: unknown; error?: unknown };\n if (r.error !== undefined && r.error !== null) return false;\n if (Array.isArray(r.data)) return r.data.length > 0;\n return r.data !== null && typeof r.data === \"object\";\n}\n\nexport interface ExpectRefusedOptions {\n /** With a write function: who tries it (and must be refused). */\n as?: DataProvider;\n /**\n * Proof a write that reached no row (`gone`) was refused, not mistyped.\n * With a write function: a provider for someone who may make it, which runs\n * the same function. With a promise: a function making the same write as\n * that someone. Either way it must resolve with the row it changed. Its\n * write is real: run the check on test data.\n */\n control?: DataProvider | (() => Promise<unknown>);\n}\n\n/**\n * The write is refused, in plain words: a refusal code (42501, P0001) or the\n * provider's `forbidden` / `unauthenticated`. A write that reaches no row\n * (`gone`) counts only with a `control` that makes the same write and changes\n * a row, so a typo'd id or resource can't pass. Pass the write as a function\n * of a provider (with `as` and a control provider) to run exactly the same\n * write both ways. Returns the refusal for more checks.\n *\n * ```ts\n * await expectRefused((p) => p.update(\"tasks\", { id, data: { title: \"x\" } }), { as: stranger, control: owner });\n * ```\n */\nexport async function expectRefused(\n write: Promise<unknown> | ((provider: DataProvider) => Promise<unknown>),\n options: ExpectRefusedOptions = {},\n): Promise<DataWriteError> {\n // A write promise may reject before an argument check below throws: mark it handled now\n // (awaiting it later still sees the rejection).\n if (typeof write !== \"function\") void write.catch(() => undefined);\n let attempt: Promise<unknown>;\n let control: (() => Promise<unknown>) | undefined;\n if (typeof write === \"function\") {\n if (!options.as) throw new ContractError(\"pass `as`: the provider that should be refused\");\n const by = options.control;\n if (by !== undefined && typeof by === \"function\") {\n throw new ContractError(\"with a write function, `control` is a provider that runs the same write\");\n }\n attempt = write(options.as);\n control = by ? () => write(by) : undefined;\n } else {\n attempt = write;\n if (options.control !== undefined && typeof options.control !== \"function\") {\n throw new ContractError(\"with a write promise, `control` is a function making the same write\");\n }\n control = options.control;\n }\n let result: unknown;\n try {\n result = await attempt;\n } catch (error) {\n if (isRefusal(error)) {\n expectPlainMessage(error);\n return error;\n }\n if (isDataWriteError(error) && error.outcome === \"gone\") {\n if (!control) {\n throw new ContractError(\n `it reached no row (${error.message}); pass a control that can make the same write, or it could be a wrong id`,\n );\n }\n let controlResult: unknown;\n try {\n controlResult = await control();\n } catch (controlError) {\n throw new ContractError(`the control failed too (${describeError(controlError)}): nothing was refused`);\n }\n if (!changedARow(controlResult)) {\n throw new ContractError(\n `the control changed no row (${JSON.stringify(controlResult)?.slice(0, 200)}): nothing was refused`,\n );\n }\n expectPlainMessage(error);\n return error;\n }\n throw new ContractError(`it failed, but not as a refusal: ${describeError(error)}`);\n }\n throw new ContractError(`it was allowed: ${JSON.stringify(result)?.slice(0, 300)}`);\n}\n\n/** The write succeeds. Returns what it resolved to. */\nexport async function expectAllowed<T>(write: Promise<T>): Promise<T> {\n try {\n return await write;\n } catch (error) {\n throw new ContractError(`it was refused: ${describeError(error)}`);\n }\n}\n\nconst stable = (value: unknown) =>\n JSON.stringify(value, (_key, v: unknown) =>\n v && typeof v === \"object\" && !Array.isArray(v)\n ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)))\n : v,\n );\n\n/**\n * A client can't change a column only the server sets. First `system` (the\n * service role, or whoever the server writes as) sets `field` to `value` and\n * must change the stored value, then puts it back: that proves the row exists\n * and the value can change at all. Then `as` tries the same update, and the\n * stored value must stay as it was, whether the update was refused, reached\n * no row, or a trigger put the value back. Any other error fails.\n */\nexport async function expectServerOwned(args: {\n as: DataProvider;\n system: DataProvider;\n resource: string;\n id: Identifier;\n field: string;\n value: unknown;\n}): Promise<void> {\n const { as, system, resource, id, field, value } = args;\n const read = async () => {\n const row = (await system.getOne<BaseRecord>(resource, { id })).data;\n if (!row || !(field in row)) throw new ContractError(`${resource} ${String(id)} has no field ${field}`);\n return row[field];\n };\n const before = await read();\n if (stable(before) === stable(value)) {\n throw new ContractError(`${field} is already ${stable(value)}: try a different value`);\n }\n const set = async (to: unknown, what: string) => {\n try {\n await system.update(resource, { id, data: { [field]: to } });\n } catch (error) {\n throw new ContractError(`the system couldn't ${what} ${field} (${describeError(error)}): nothing was tested`);\n }\n };\n await set(value, \"set\");\n const changed = await read();\n await set(before, \"put back\");\n if (stable(changed) === stable(before)) {\n throw new ContractError(`the system couldn't change ${field} either: nothing was tested`);\n }\n if (stable(await read()) !== stable(before)) {\n throw new ContractError(`the system couldn't put ${field} back to ${stable(before)}`);\n }\n try {\n await as.update(resource, { id, data: { [field]: value } });\n } catch (error) {\n if (!isRefusal(error) && !(isDataWriteError(error) && error.outcome === \"gone\")) {\n throw new ContractError(`setting ${field} failed, but not as a refusal: ${describeError(error)}`);\n }\n }\n const after = await read();\n if (stable(after) !== stable(before)) {\n // Put the value back, so a failed check doesn't change what the next one sees.\n await system.update(resource, { id, data: { [field]: before } }).catch(() => undefined);\n throw new ContractError(`${resource}.${field} changed from ${stable(before)} to ${stable(after)}`);\n }\n}\n\n/**\n * `marker` (a value only a private row holds) appears nowhere in what the read\n * returns: use a list or report read that returns the rows around the private\n * one. A refused read (42501, or `forbidden` / `unauthenticated`) shows\n * nothing and passes; any other failure fails, `gone` included, so a wrong id\n * or a broken query can't pass.\n */\nexport async function expectNeverShows(read: Promise<unknown>, marker: string): Promise<void> {\n if (!marker) throw new ContractError(\"give a marker to look for\");\n let result: unknown;\n try {\n result = await read;\n } catch (error) {\n const refused =\n (isDataReadError(error) || isDataWriteError(error)) &&\n ((error.code !== undefined && REFUSAL_CODES.has(error.code)) ||\n error.outcome === \"forbidden\" ||\n error.outcome === \"unauthenticated\");\n if (refused) return;\n throw new ContractError(`the read failed: ${describeError(error)}`);\n }\n if (result === undefined) throw new ContractError(\"the read returned nothing to look through\");\n if (result && typeof result === \"object\" && \"error\" in result && (result as { error: unknown }).error != null) {\n throw new ContractError(`the read failed: ${JSON.stringify((result as { error: unknown }).error)?.slice(0, 200)}`);\n }\n const text = JSON.stringify(result) ?? \"\";\n const escaped = JSON.stringify(marker).slice(1, -1);\n if (text.includes(marker) || text.includes(escaped)) {\n throw new ContractError(`\"${marker}\" appears in: ${text.slice(0, 300)}`);\n }\n}\n\n/** The read returns exactly `expected` rows (a list result, a getMany result or an array). */\nexport async function expectSees(read: Promise<unknown>, expected: number): Promise<void> {\n let result: unknown;\n try {\n result = await read;\n } catch (error) {\n throw new ContractError(`the read failed: ${error instanceof Error ? error.message : String(error)}`);\n }\n const rows = Array.isArray(result)\n ? result\n : result && typeof result === \"object\" && Array.isArray((result as { data?: unknown }).data)\n ? (result as { data: unknown[] }).data\n : undefined;\n if (!rows) throw new ContractError(`the read returned no rows to count: ${JSON.stringify(result)?.slice(0, 300)}`);\n if (rows.length !== expected) throw new ContractError(`saw ${rows.length} row(s), expected ${expected}`);\n}\n\n/**\n * A delete on a recycle-bin table moves the row to the bin: `as` deletes it,\n * can no longer read it (gone or forbidden), and `inBin` (a check with the\n * service role or a bin query) confirms the row is still stored.\n */\nexport async function expectBinned(args: {\n as: DataProvider;\n resource: string;\n id: Identifier;\n inBin: () => Promise<boolean>;\n}): Promise<void> {\n const { as, resource, id, inBin } = args;\n // Visible first, so \"can't read it back\" is evidence of the delete.\n try {\n await as.getOne(resource, { id });\n } catch (error) {\n throw new ContractError(`${resource} ${String(id)} isn't visible before the delete: ${describeError(error)}`);\n }\n await expectAllowed(as.deleteOne(resource, { id }));\n try {\n await as.getOne(resource, { id });\n } catch (error) {\n if (!isDataReadError(error) || (error.outcome !== \"gone\" && error.outcome !== \"forbidden\")) {\n throw new ContractError(`reading it back failed: ${describeError(error)}`);\n }\n if (!(await inBin())) throw new ContractError(`${resource} ${String(id)} was deleted for good, not binned`);\n return;\n }\n throw new ContractError(`${resource} ${String(id)} is still visible after the delete`);\n}\n"]}
@@ -59,33 +59,4 @@ declare class DataWriteError extends Error {
59
59
  */
60
60
  declare function isDataWriteError(error: unknown): error is DataWriteError;
61
61
 
62
- /**
63
- * - `unauthenticated`: no signed-in user, or the session expired.
64
- * - `forbidden`: this user may not read these records.
65
- * - `gone`: the one record asked for doesn't exist, or isn't visible.
66
- * - `invalid`: the request itself was wrong (an unknown column, a bad value).
67
- * - `error`: anything else.
68
- */
69
- type ReadOutcome = "unauthenticated" | "forbidden" | "gone" | "invalid" | "error";
70
- interface DataReadErrorOptions {
71
- /** The store's own error code (a Postgres SQLSTATE or PostgREST code). */
72
- code?: string;
73
- /** The underlying error, with its raw text, kept for logging. */
74
- cause?: unknown;
75
- }
76
- /** Thrown by a provider when a read failed. Its `message` is safe to show. */
77
- declare class DataReadError extends Error {
78
- readonly outcome: ReadOutcome;
79
- /** The store's own error code, when it gave one. */
80
- readonly code?: string;
81
- constructor(outcome: ReadOutcome, message: string, options?: DataReadErrorOptions);
82
- }
83
- /**
84
- * `instanceof` fails when two copies of this package are installed, so check
85
- * the shape instead.
86
- */
87
- declare function isDataReadError(error: unknown): error is DataReadError;
88
- /** The plain sentence for a failed read of `noun` records (one, or a list). */
89
- declare function readFailureMessage(outcome: ReadOutcome, noun: string, one: boolean, plural?: string): string;
90
-
91
- export { DataReadError as D, type ReadOutcome as R, type WriteOutcome as W, type DataReadErrorOptions as a, DataWriteError as b, type DataWriteErrorOptions as c, isDataWriteError as d, isDataReadError as i, readFailureMessage as r };
62
+ export { DataWriteError as D, type WriteOutcome as W, type DataWriteErrorOptions as a, isDataWriteError as i };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-core",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -51,6 +51,10 @@
51
51
  "types": "./dist/navigation.d.ts",
52
52
  "import": "./dist/navigation.js"
53
53
  },
54
+ "./testing": {
55
+ "types": "./dist/testing/index.d.ts",
56
+ "import": "./dist/testing/index.js"
57
+ },
54
58
  "./dates": {
55
59
  "types": "./dist/dates.d.ts",
56
60
  "import": "./dist/dates.js"