@fuzdev/fuz_app 0.80.0 → 0.81.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/dist/testing/CLAUDE.md +4 -2
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +165 -1
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +3 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/schema_introspect.d.ts +16 -0
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +28 -0
- package/dist/testing/schema_parity.d.ts +9 -1
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/schema_parity.js +28 -0
- package/package.json +2 -1
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -209,7 +209,7 @@ test-only by construction.
|
|
|
209
209
|
|
|
210
210
|
### `schema_introspect.ts` — `query_schema_snapshot`
|
|
211
211
|
|
|
212
|
-
- `query_schema_snapshot(db, options?)` — introspects a live DB into a deterministic `SchemaSnapshot` via `pg_catalog` + `information_schema`. Captures tables, columns (with `udt_name` to distinguish int4/int8), indexes (`indexdef`), constraints (`pg_get_constraintdef`), and
|
|
212
|
+
- `query_schema_snapshot(db, options?)` — introspects a live DB into a deterministic `SchemaSnapshot` via `pg_catalog` + `information_schema`. Captures tables, columns (with `udt_name` to distinguish int4/int8), indexes (`indexdef`), constraints (`pg_get_constraintdef`), sequences, and enum types (`pg_enum` labels in declared `enumsortorder`, so a `cell_visibility` label-set/order drift is gated). The `schema_version` migration tracker is always excluded — it's framework bookkeeping, not domain schema, and impls organize migration namespaces differently. Twinned by `fuz_db::query_schema_snapshot` (Rust); the `_testing_schema_snapshot` RPC's wire validator is the shared `SchemaSnapshot`, so the enum field must serialize on both sides.
|
|
213
213
|
- `SchemaSnapshot` — the Zod schema is canonical (co-located in `schema_introspect.ts`; the cross-impl `_testing_schema_snapshot` RPC action reuses it as its wire validator, and the type is `z.infer`'d from it). Fully JSON-serializable; every collection deterministically sorted on capture so structural equality is stable across runs.
|
|
214
214
|
|
|
215
215
|
### `schema_parity.ts` — `assert_schema_snapshots_equal`
|
|
@@ -217,7 +217,9 @@ test-only by construction.
|
|
|
217
217
|
- `diff_schema_snapshots(a, b)` — structured `Array<SchemaDiff>` between two snapshots; empty array means parity holds.
|
|
218
218
|
- `format_schema_diffs(diffs, labels?)` — human-readable multi-line rendering; labels name the impl on each side (e.g., `{a: 'deno', b: 'rust'}`).
|
|
219
219
|
- `assert_schema_snapshots_equal(a, b, labels?)` — throws on drift with a fully-formatted diff message.
|
|
220
|
-
- `SchemaDiff` — tagged-union per drift kind: `table_only_in`, `column_only_in`, `column_field_differs`, `index_only_in`, `index_definition_differs`, `constraint_only_in`, `constraint_differs`, `sequence_only_in`, `sequence_data_type_differs
|
|
220
|
+
- `SchemaDiff` — tagged-union per drift kind: `table_only_in`, `column_only_in`, `column_field_differs`, `index_only_in`, `index_definition_differs`, `constraint_only_in`, `constraint_differs`, `sequence_only_in`, `sequence_data_type_differs`, `enum_only_in`, `enum_labels_differ` (enum labels compared positionally — declared order is significant).
|
|
221
|
+
|
|
222
|
+
fuz_app's own spine gates this cross-process via the `cross_backend_schema_parity` project (`schema_parity.cross.test.ts` + the dual-spawn `global_setup_schema_parity.ts`), diffing the TS spine ↔ `testing_spine_stub` full schema — `npm run test:cross:schema-parity`. The forge has its own deno↔rust parity gate.
|
|
221
223
|
|
|
222
224
|
**Cross-impl gate pattern** — a dual-impl consumer running two backends
|
|
223
225
|
(a TS Hono server and a Rust spine server) against a shared schema, plus
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAoF9B,OAAO,EAIN,KAAK,oBAAoB,EACzB,MAAM,yBAAyB,CAAC;AAsFjC,eAAO,MAAM,mCAAmC,GAAI,SAAS,oBAAoB,KAAG,IAyvBnF,CAAC"}
|
|
@@ -30,6 +30,17 @@ import '../assert_dev_env.js';
|
|
|
30
30
|
* - **audit** — `cell_audit_list` is manage-tier: the owner reads the cell's
|
|
31
31
|
* timeline; a viewer-grant holder who can `cell_get` the cell still gets the
|
|
32
32
|
* IDOR 404 on the timeline (D14).
|
|
33
|
+
* - **relation-read visibility (D8)** — listing edges toward a cell the caller
|
|
34
|
+
* can't view filters them out (no-existence-leak-via-edge): an anonymous
|
|
35
|
+
* viewer of a public parent — and a viewer-grant holder of a private parent —
|
|
36
|
+
* sees only independently-viewable children in the `cell_get` bundle and the
|
|
37
|
+
* forward `cell_item_list` / `cell_field_list`. The cross twin of the
|
|
38
|
+
* in-process `auth/cell_relation_visibility.db.test.ts`.
|
|
39
|
+
* - **clone D8** — a cloner who can view a public parent but not a private child
|
|
40
|
+
* silently drops that child: an admin (who *can* view it) reading the clone
|
|
41
|
+
* still sees only the viewable edge/child, and the `cell_clone` audit row
|
|
42
|
+
* records no skipped-child count — so the source's hidden-child count can't
|
|
43
|
+
* leak to the cloner. The cross twin of `auth/cell_actions.clone.db.test.ts`.
|
|
33
44
|
*
|
|
34
45
|
* Only **actor-shaped** grants are exercised — role-shaped principals need a
|
|
35
46
|
* closed role registry the Rust spine deliberately lacks, so role-grant
|
|
@@ -42,7 +53,8 @@ import '../assert_dev_env.js';
|
|
|
42
53
|
*/
|
|
43
54
|
import { describe, assert } from 'vitest';
|
|
44
55
|
import { fractional_index_between } from '@fuzdev/fuz_util/fractional_index.js';
|
|
45
|
-
import { CellCreateOutput, CellUpdateOutput, CellCloneOutput } from '../../auth/cell_action_specs.js';
|
|
56
|
+
import { CellCreateOutput, CellUpdateOutput, CellCloneOutput, CellGetOutput, } from '../../auth/cell_action_specs.js';
|
|
57
|
+
import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
|
|
46
58
|
import { CellGrantCreateOutput, CellGrantListOutput, CellGrantRevokeOutput, } from '../../auth/cell_grant_action_specs.js';
|
|
47
59
|
import { CellFieldDeleteOutput, CellFieldListOutput, CellFieldSetOutput, } from '../../auth/cell_field_action_specs.js';
|
|
48
60
|
import { CellItemDeleteOutput, CellItemInsertOutput, CellItemListOutput, CellItemMoveOutput, } from '../../auth/cell_item_action_specs.js';
|
|
@@ -50,6 +62,33 @@ import { CellAuditListOutput } from '../../auth/cell_audit_action_specs.js';
|
|
|
50
62
|
import { test_if } from './capabilities.js';
|
|
51
63
|
import { cross_rpc_call, error_reason, expect_output, } from './cell_cross_helpers.js';
|
|
52
64
|
import { SPINE_RPC_PATH } from './default_spine_surface.js';
|
|
65
|
+
/** Create a cell over the wire and return its id (the parity gate parses the output). */
|
|
66
|
+
const create_cell = async (t, rpc_path, h, params) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', params, h), CellCreateOutput).cell
|
|
67
|
+
.id;
|
|
68
|
+
/**
|
|
69
|
+
* Wire `pub_child` + `priv_child` under `parent` as both ordered items and
|
|
70
|
+
* named fields, using the owner's headers (owner can edit parent + view both
|
|
71
|
+
* children). The cross-process twin of the in-process `wire_children` in
|
|
72
|
+
* `auth/cell_relation_visibility.db.test.ts`.
|
|
73
|
+
*/
|
|
74
|
+
const wire_children = async (t, rpc_path, owner_h, parent, pub_child, priv_child) => {
|
|
75
|
+
const pos_a = fractional_index_between(null, null);
|
|
76
|
+
const pos_b = fractional_index_between(pos_a, null);
|
|
77
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: parent, child_id: pub_child, position: pos_a }, owner_h), CellItemInsertOutput);
|
|
78
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: parent, child_id: priv_child, position: pos_b }, owner_h), CellItemInsertOutput);
|
|
79
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: parent, name: 'pub_link', target_id: pub_child }, owner_h), CellFieldSetOutput);
|
|
80
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: parent, name: 'priv_link', target_id: priv_child }, owner_h), CellFieldSetOutput);
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Await in-flight fire-and-forget audit writes so a following `audit_log_list`
|
|
84
|
+
* is authoritative on both spines (the real await on Rust; satisfied by
|
|
85
|
+
* construction on the TS spine's `await_pending_effects`). Mirrors the
|
|
86
|
+
* `account_lifecycle` cross suite's barrier.
|
|
87
|
+
*/
|
|
88
|
+
const drain_effects = async (td, rpc_path, daemon_h) => {
|
|
89
|
+
const drained = await cross_rpc_call(td, rpc_path, '_testing_drain_effects', undefined, daemon_h);
|
|
90
|
+
assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
|
|
91
|
+
};
|
|
53
92
|
export const describe_cell_relations_cross_tests = (options) => {
|
|
54
93
|
const { setup_test, capabilities } = options;
|
|
55
94
|
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
@@ -225,5 +264,130 @@ export const describe_cell_relations_cross_tests = (options) => {
|
|
|
225
264
|
assert.ok(!denied.ok, 'viewer read the audit timeline');
|
|
226
265
|
assert.strictEqual(error_reason(denied), 'cell_not_found');
|
|
227
266
|
});
|
|
267
|
+
test_if(capabilities.cell_relations, 'relation reads filter non-viewable targets (D8): anon sees only public children', async () => {
|
|
268
|
+
const fixture = await setup_test();
|
|
269
|
+
const owner = await fixture.create_account({ username: 'cell_relvis_owner' });
|
|
270
|
+
const t = fixture.fresh_transport();
|
|
271
|
+
const owner_h = owner.create_session_headers();
|
|
272
|
+
// A public parent linking one public + one private child, as both
|
|
273
|
+
// items and fields. The private child is owned by `owner`, so only
|
|
274
|
+
// `owner` (and admin) can view it.
|
|
275
|
+
const parent = await create_cell(t, rpc_path, owner_h, {
|
|
276
|
+
data: { kind: 'collection' },
|
|
277
|
+
visibility: 'public',
|
|
278
|
+
});
|
|
279
|
+
const pub_child = await create_cell(t, rpc_path, owner_h, {
|
|
280
|
+
data: { kind: 'note' },
|
|
281
|
+
visibility: 'public',
|
|
282
|
+
});
|
|
283
|
+
const priv_child = await create_cell(t, rpc_path, owner_h, { data: { kind: 'note' } });
|
|
284
|
+
await wire_children(t, rpc_path, owner_h, parent, pub_child, priv_child);
|
|
285
|
+
// Anonymous: only the public child surfaces in the bundle …
|
|
286
|
+
const anon = fixture.fresh_transport({ origin: null });
|
|
287
|
+
const bundle = expect_output(await cross_rpc_call(anon, rpc_path, 'cell_get', { id: parent }, {}), CellGetOutput);
|
|
288
|
+
assert.deepStrictEqual(bundle.items.map((i) => i.child_id), [pub_child], 'anon bundle leaked the private child item');
|
|
289
|
+
assert.deepStrictEqual(bundle.fields.map((f) => f.target_id), [pub_child], 'anon bundle leaked the private child field');
|
|
290
|
+
// … and in the forward paginating list verbs.
|
|
291
|
+
const items = expect_output(await cross_rpc_call(anon, rpc_path, 'cell_item_list', { parent_id: parent }, {}), CellItemListOutput);
|
|
292
|
+
assert.deepStrictEqual(items.items.map((i) => i.child_id), [pub_child], 'anon item_list leaked the private child');
|
|
293
|
+
const fields = expect_output(await cross_rpc_call(anon, rpc_path, 'cell_field_list', { source_id: parent }, {}), CellFieldListOutput);
|
|
294
|
+
assert.deepStrictEqual(fields.fields.map((f) => f.target_id), [pub_child], 'anon field_list leaked the private child');
|
|
295
|
+
});
|
|
296
|
+
test_if(capabilities.cell_relations, 'relation reads filter non-viewable targets (D8): viewer-grant sees only independently-viewable children', async () => {
|
|
297
|
+
const fixture = await setup_test();
|
|
298
|
+
const owner = await fixture.create_account({ username: 'cell_relvis_grant_owner' });
|
|
299
|
+
const viewer = await fixture.create_account({ username: 'cell_relvis_viewer' });
|
|
300
|
+
const t = fixture.fresh_transport();
|
|
301
|
+
const owner_h = owner.create_session_headers();
|
|
302
|
+
const viewer_h = viewer.create_session_headers();
|
|
303
|
+
// Private parent; one child the viewer will be granted, one they won't.
|
|
304
|
+
const parent = await create_cell(t, rpc_path, owner_h, { data: { kind: 'collection' } });
|
|
305
|
+
const shared_child = await create_cell(t, rpc_path, owner_h, { data: { kind: 'note' } });
|
|
306
|
+
const priv_child = await create_cell(t, rpc_path, owner_h, { data: { kind: 'note' } });
|
|
307
|
+
await wire_children(t, rpc_path, owner_h, parent, shared_child, priv_child);
|
|
308
|
+
// Grant the viewer on parent AND shared_child — but not priv_child.
|
|
309
|
+
for (const cell_id of [parent, shared_child]) {
|
|
310
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', { cell_id, level: 'viewer', principal: { kind: 'actor', actor_id: viewer.actor.id } }, owner_h), CellGrantCreateOutput);
|
|
311
|
+
}
|
|
312
|
+
// The viewer can reach the parent, but its bundle exposes only the
|
|
313
|
+
// child they can independently view.
|
|
314
|
+
const bundle = expect_output(await cross_rpc_call(t, rpc_path, 'cell_get', { id: parent }, viewer_h), CellGetOutput);
|
|
315
|
+
assert.deepStrictEqual(bundle.items.map((i) => i.child_id), [shared_child], 'viewer bundle leaked the un-granted child item');
|
|
316
|
+
assert.deepStrictEqual(bundle.fields.map((f) => f.target_id), [shared_child], 'viewer bundle leaked the un-granted child field');
|
|
317
|
+
});
|
|
318
|
+
test_if(capabilities.cell_relations, 'clone D8: shallow drops edges to non-viewable children (no count leak)', async () => {
|
|
319
|
+
const fixture = await setup_test();
|
|
320
|
+
const owner = await fixture.create_account({ username: 'cell_clone_d8_owner' });
|
|
321
|
+
const cloner = await fixture.create_account({ username: 'cell_clone_d8_cloner' });
|
|
322
|
+
const t = fixture.fresh_transport();
|
|
323
|
+
const owner_h = owner.create_session_headers();
|
|
324
|
+
const cloner_h = cloner.create_session_headers();
|
|
325
|
+
// The keeper holds [keeper, admin], so its headers are the admin probe.
|
|
326
|
+
const admin_h = fixture.create_session_headers();
|
|
327
|
+
const parent = await create_cell(t, rpc_path, owner_h, {
|
|
328
|
+
data: { kind: 'collection' },
|
|
329
|
+
visibility: 'public',
|
|
330
|
+
});
|
|
331
|
+
const pub_child = await create_cell(t, rpc_path, owner_h, {
|
|
332
|
+
data: { kind: 'note' },
|
|
333
|
+
visibility: 'public',
|
|
334
|
+
});
|
|
335
|
+
const priv_child = await create_cell(t, rpc_path, owner_h, { data: { kind: 'note' } });
|
|
336
|
+
await wire_children(t, rpc_path, owner_h, parent, pub_child, priv_child);
|
|
337
|
+
// The cloner can read the public parent but not the private child.
|
|
338
|
+
const clone = expect_output(await cross_rpc_call(t, rpc_path, 'cell_clone', { source_id: parent }, cloner_h), CellCloneOutput).cell;
|
|
339
|
+
assert.strictEqual(clone.created_by, cloner.actor.id);
|
|
340
|
+
// The ADMIN can view the private child, so an admin read of the clone
|
|
341
|
+
// would surface the private edge if it had been copied — it must not.
|
|
342
|
+
const admin_items = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { parent_id: clone.id }, admin_h), CellItemListOutput);
|
|
343
|
+
assert.deepStrictEqual(admin_items.items.map((i) => i.child_id), [pub_child], 'shallow clone copied the non-viewable item edge');
|
|
344
|
+
const admin_fields = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { source_id: clone.id }, admin_h), CellFieldListOutput);
|
|
345
|
+
assert.deepStrictEqual(admin_fields.fields.map((f) => f.target_id), [pub_child], 'shallow clone copied the non-viewable field edge');
|
|
346
|
+
// No skipped-child count surfaced in the clone's audit row — the
|
|
347
|
+
// hidden child's existence must not leak to the cloner. (`_testing_reset`
|
|
348
|
+
// wiped audit_log at setup, so this is the only cell_clone event.)
|
|
349
|
+
const td = fixture.fresh_transport({ origin: null });
|
|
350
|
+
await drain_effects(td, rpc_path, fixture.create_daemon_token_headers());
|
|
351
|
+
const events = expect_output(await cross_rpc_call(t, rpc_path, 'audit_log_list', { event_type: 'cell_clone' }, admin_h), AuditLogListOutput).events;
|
|
352
|
+
const ev = events.find((e) => e.metadata?.new_id === clone.id);
|
|
353
|
+
assert.ok(ev, 'no cell_clone audit row for the shallow clone');
|
|
354
|
+
assert.strictEqual(ev.metadata.skipped_item_count, undefined, 'shallow clone leaked the hidden-child count via skipped_item_count');
|
|
355
|
+
});
|
|
356
|
+
test_if(capabilities.cell_relations, 'clone D8: deep silently skips non-viewable children (no count leak)', async () => {
|
|
357
|
+
const fixture = await setup_test();
|
|
358
|
+
const owner = await fixture.create_account({ username: 'cell_clone_d8_deep_owner' });
|
|
359
|
+
const cloner = await fixture.create_account({ username: 'cell_clone_d8_deep_cloner' });
|
|
360
|
+
const t = fixture.fresh_transport();
|
|
361
|
+
const owner_h = owner.create_session_headers();
|
|
362
|
+
const cloner_h = cloner.create_session_headers();
|
|
363
|
+
const admin_h = fixture.create_session_headers();
|
|
364
|
+
const parent = await create_cell(t, rpc_path, owner_h, {
|
|
365
|
+
data: { kind: 'collection' },
|
|
366
|
+
visibility: 'public',
|
|
367
|
+
});
|
|
368
|
+
const pub_child = await create_cell(t, rpc_path, owner_h, {
|
|
369
|
+
data: { kind: 'note' },
|
|
370
|
+
visibility: 'public',
|
|
371
|
+
});
|
|
372
|
+
const priv_child = await create_cell(t, rpc_path, owner_h, { data: { kind: 'note' } });
|
|
373
|
+
await wire_children(t, rpc_path, owner_h, parent, pub_child, priv_child);
|
|
374
|
+
const clone = expect_output(await cross_rpc_call(t, rpc_path, 'cell_clone', { source_id: parent, deep: true }, cloner_h), CellCloneOutput).cell;
|
|
375
|
+
// Deep clone copies viewable children into fresh cells; the
|
|
376
|
+
// non-viewable child is dropped. The admin (who can view everything)
|
|
377
|
+
// reading the clone still sees exactly one item — proving the private
|
|
378
|
+
// child was never cloned, not merely filtered from the cloner's view.
|
|
379
|
+
const admin_items = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { parent_id: clone.id }, admin_h), CellItemListOutput);
|
|
380
|
+
assert.strictEqual(admin_items.items.length, 1, 'deep clone cloned the non-viewable child');
|
|
381
|
+
assert.notStrictEqual(admin_items.items[0].child_id, pub_child, 'deep clone reused the original child instead of cloning it');
|
|
382
|
+
const td = fixture.fresh_transport({ origin: null });
|
|
383
|
+
await drain_effects(td, rpc_path, fixture.create_daemon_token_headers());
|
|
384
|
+
const events = expect_output(await cross_rpc_call(t, rpc_path, 'audit_log_list', { event_type: 'cell_clone' }, admin_h), AuditLogListOutput).events;
|
|
385
|
+
const ev = events.find((e) => e.metadata?.new_id === clone.id);
|
|
386
|
+
assert.ok(ev, 'no cell_clone audit row for the deep clone');
|
|
387
|
+
const meta = ev.metadata;
|
|
388
|
+
assert.strictEqual(meta.deep, true);
|
|
389
|
+
assert.strictEqual(meta.item_count, 1, 'deep clone counted a non-viewable child');
|
|
390
|
+
assert.strictEqual(meta.skipped_item_count, undefined, 'deep clone leaked the hidden-child count via skipped_item_count');
|
|
391
|
+
});
|
|
228
392
|
});
|
|
229
393
|
};
|
|
@@ -307,6 +307,9 @@ export declare const testing_schema_snapshot_action_spec: {
|
|
|
307
307
|
sequences: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
308
308
|
data_type: z.ZodString;
|
|
309
309
|
}, z.core.$strip>>;
|
|
310
|
+
enums: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
311
|
+
labels: z.ZodArray<z.ZodString>;
|
|
312
|
+
}, z.core.$strip>>;
|
|
310
313
|
}, z.core.$strip>;
|
|
311
314
|
readonly async: true;
|
|
312
315
|
readonly description: "Test-binary only — introspect the live schema into a normalized snapshot for cross-impl parity diffing.";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,OAAO,EAAa,KAAK,SAAS,EAAC,MAAM,6BAA6B,CAAC;AAGvE,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;AACjE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,gBAAgB,CAAC;AAmBvC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAiBpC;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWyC,CAAC;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;CAWA,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;CAeC,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,mCAAmC,QAAO,SACiB,CAAC;AAEzE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;CAeK,CAAC;AAE/C;;;;;;;;;GASG;AACH,eAAO,MAAM,mCAAmC
|
|
1
|
+
{"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,OAAO,EAAa,KAAK,SAAS,EAAC,MAAM,6BAA6B,CAAC;AAGvE,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;AACjE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,gBAAgB,CAAC;AAmBvC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAiBpC;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWyC,CAAC;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;CAWA,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;CAeC,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,mCAAmC,QAAO,SACiB,CAAC;AAEzE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;CAeK,CAAC;AAE/C;;;;;;;;;GASG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWF,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,qCAAqC,QAAO,SAGvD,CAAC;AAEH,4CAA4C;AAC5C,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD;;;;;OAKG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,OAAO,EACb,SAAS,2BAA2B,KAClC,KAAK,CAAC,SAAS,CAqIjB,CAAC;AAEF,0FAA0F;AAC1F,eAAO,MAAM,0BAA0B,UAAmC,CAAC"}
|
|
@@ -10,6 +10,9 @@ import './assert_dev_env.js';
|
|
|
10
10
|
* - Constraints (CHECK, FOREIGN KEY, PRIMARY KEY, UNIQUE, EXCLUSION)
|
|
11
11
|
* - Sequences with data type — distinguishes `int4` (SERIAL) from `int8`
|
|
12
12
|
* (BIGSERIAL)
|
|
13
|
+
* - Enum types (`CREATE TYPE ... AS ENUM`) with labels in declared order —
|
|
14
|
+
* so a label set / ordering drift (e.g. `cell_visibility`) is a gated fact,
|
|
15
|
+
* not invisible
|
|
13
16
|
*
|
|
14
17
|
* Designed for `pg_catalog` introspection — works against both PostgreSQL
|
|
15
18
|
* and PGlite. The snapshot is fully deterministic: every collection sorts by
|
|
@@ -60,6 +63,16 @@ export declare const SequenceSnapshot: z.ZodObject<{
|
|
|
60
63
|
data_type: z.ZodString;
|
|
61
64
|
}, z.core.$strip>;
|
|
62
65
|
export type SequenceSnapshot = z.infer<typeof SequenceSnapshot>;
|
|
66
|
+
/**
|
|
67
|
+
* Enum-type metadata — the labels of a `CREATE TYPE ... AS ENUM`, captured in
|
|
68
|
+
* `pg_enum.enumsortorder` (declaration) order. Order is significant: a Postgres
|
|
69
|
+
* enum's labels are an ordered set, and reordering them is a schema change, so
|
|
70
|
+
* the parity diff compares the arrays positionally.
|
|
71
|
+
*/
|
|
72
|
+
export declare const EnumTypeSnapshot: z.ZodObject<{
|
|
73
|
+
labels: z.ZodArray<z.ZodString>;
|
|
74
|
+
}, z.core.$strip>;
|
|
75
|
+
export type EnumTypeSnapshot = z.infer<typeof EnumTypeSnapshot>;
|
|
63
76
|
/**
|
|
64
77
|
* Normalized database schema snapshot for parity comparison — the single
|
|
65
78
|
* source of truth for the snapshot shape across the introspection query
|
|
@@ -91,6 +104,9 @@ export declare const SchemaSnapshot: z.ZodObject<{
|
|
|
91
104
|
sequences: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
92
105
|
data_type: z.ZodString;
|
|
93
106
|
}, z.core.$strip>>;
|
|
107
|
+
enums: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
108
|
+
labels: z.ZodArray<z.ZodString>;
|
|
109
|
+
}, z.core.$strip>>;
|
|
94
110
|
}, z.core.$strip>;
|
|
95
111
|
export type SchemaSnapshot = z.infer<typeof SchemaSnapshot>;
|
|
96
112
|
/** Filter options for `query_schema_snapshot`. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema_introspect.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_introspect.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B
|
|
1
|
+
{"version":3,"file":"schema_introspect.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_introspect.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,aAAa,CAAC;AAEpC;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;iBAWzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,qCAAqC;AACrC,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;iBAOxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,gBAAgB;;iBAE3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;;iBAG3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;iBAOzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,kDAAkD;AAClD,MAAM,WAAW,0BAA0B;IAC1C;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChD;AA8DD;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,GACjC,IAAI,EAAE,EACN,UAAS,0BAA+B,KACtC,OAAO,CAAC,cAAc,CAyHxB,CAAC"}
|
|
@@ -10,6 +10,9 @@ import './assert_dev_env.js';
|
|
|
10
10
|
* - Constraints (CHECK, FOREIGN KEY, PRIMARY KEY, UNIQUE, EXCLUSION)
|
|
11
11
|
* - Sequences with data type — distinguishes `int4` (SERIAL) from `int8`
|
|
12
12
|
* (BIGSERIAL)
|
|
13
|
+
* - Enum types (`CREATE TYPE ... AS ENUM`) with labels in declared order —
|
|
14
|
+
* so a label set / ordering drift (e.g. `cell_visibility`) is a gated fact,
|
|
15
|
+
* not invisible
|
|
13
16
|
*
|
|
14
17
|
* Designed for `pg_catalog` introspection — works against both PostgreSQL
|
|
15
18
|
* and PGlite. The snapshot is fully deterministic: every collection sorts by
|
|
@@ -51,6 +54,16 @@ export const TableSnapshot = z.object({
|
|
|
51
54
|
export const SequenceSnapshot = z.object({
|
|
52
55
|
data_type: z.string(),
|
|
53
56
|
});
|
|
57
|
+
/**
|
|
58
|
+
* Enum-type metadata — the labels of a `CREATE TYPE ... AS ENUM`, captured in
|
|
59
|
+
* `pg_enum.enumsortorder` (declaration) order. Order is significant: a Postgres
|
|
60
|
+
* enum's labels are an ordered set, and reordering them is a schema change, so
|
|
61
|
+
* the parity diff compares the arrays positionally.
|
|
62
|
+
*/
|
|
63
|
+
export const EnumTypeSnapshot = z.object({
|
|
64
|
+
/** Enum labels in declared order. */
|
|
65
|
+
labels: z.array(z.string()),
|
|
66
|
+
});
|
|
54
67
|
/**
|
|
55
68
|
* Normalized database schema snapshot for parity comparison — the single
|
|
56
69
|
* source of truth for the snapshot shape across the introspection query
|
|
@@ -65,6 +78,8 @@ export const SchemaSnapshot = z.object({
|
|
|
65
78
|
tables: z.record(z.string(), TableSnapshot),
|
|
66
79
|
/** Sequences keyed by name. */
|
|
67
80
|
sequences: z.record(z.string(), SequenceSnapshot),
|
|
81
|
+
/** Enum types (`CREATE TYPE ... AS ENUM`) keyed by name; labels in declared order. */
|
|
82
|
+
enums: z.record(z.string(), EnumTypeSnapshot),
|
|
68
83
|
});
|
|
69
84
|
const sort_keys = (record) => {
|
|
70
85
|
const sorted = {};
|
|
@@ -145,6 +160,14 @@ export const query_schema_snapshot = async (db, options = {}) => {
|
|
|
145
160
|
FROM information_schema.sequences
|
|
146
161
|
WHERE sequence_schema = $1
|
|
147
162
|
ORDER BY sequence_name ASC`, [schema]);
|
|
163
|
+
// Enum types — one row per label, ordered by enumsortorder so labels
|
|
164
|
+
// accumulate in declared order. Grouped client-side, mirroring columns.
|
|
165
|
+
const enum_rows = await db.query(`SELECT t.typname AS enum_name, e.enumlabel AS label
|
|
166
|
+
FROM pg_type t
|
|
167
|
+
JOIN pg_enum e ON e.enumtypid = t.oid
|
|
168
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
169
|
+
WHERE n.nspname = $1
|
|
170
|
+
ORDER BY t.typname ASC, e.enumsortorder ASC`, [schema]);
|
|
148
171
|
const tables = {};
|
|
149
172
|
for (const name of table_names) {
|
|
150
173
|
const columns = {};
|
|
@@ -178,8 +201,13 @@ export const query_schema_snapshot = async (db, options = {}) => {
|
|
|
178
201
|
for (const row of sequence_rows) {
|
|
179
202
|
sequences[row.sequence_name] = { data_type: row.data_type };
|
|
180
203
|
}
|
|
204
|
+
const enums = {};
|
|
205
|
+
for (const row of enum_rows) {
|
|
206
|
+
(enums[row.enum_name] ??= { labels: [] }).labels.push(row.label);
|
|
207
|
+
}
|
|
181
208
|
return {
|
|
182
209
|
tables: sort_keys(tables),
|
|
183
210
|
sequences: sort_keys(sequences),
|
|
211
|
+
enums: sort_keys(enums),
|
|
184
212
|
};
|
|
185
213
|
};
|
|
@@ -20,7 +20,6 @@ import './assert_dev_env.js';
|
|
|
20
20
|
*
|
|
21
21
|
* Non-coverage — drift the gate does **not** detect:
|
|
22
22
|
*
|
|
23
|
-
* - enum types (`CREATE TYPE ... AS ENUM`)
|
|
24
23
|
* - regular triggers (`pg_trigger`); `CONSTRAINT TRIGGER` is captured via
|
|
25
24
|
* pg_constraint, but standalone `CREATE TRIGGER` is not
|
|
26
25
|
* - views, materialized views, functions, procedures
|
|
@@ -95,6 +94,15 @@ export type SchemaDiff = {
|
|
|
95
94
|
readonly sequence: string;
|
|
96
95
|
readonly a: string;
|
|
97
96
|
readonly b: string;
|
|
97
|
+
} | {
|
|
98
|
+
readonly kind: 'enum_only_in';
|
|
99
|
+
readonly where: 'a' | 'b';
|
|
100
|
+
readonly enum_name: string;
|
|
101
|
+
} | {
|
|
102
|
+
readonly kind: 'enum_labels_differ';
|
|
103
|
+
readonly enum_name: string;
|
|
104
|
+
readonly a: ReadonlyArray<string>;
|
|
105
|
+
readonly b: ReadonlyArray<string>;
|
|
98
106
|
};
|
|
99
107
|
/**
|
|
100
108
|
* Structural diff between two snapshots — empty array means parity holds.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_parity.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B
|
|
1
|
+
{"version":3,"file":"schema_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_parity.ts"],"names":[],"mappings":"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"}
|
|
@@ -36,6 +36,20 @@ export const diff_schema_snapshots = (a, b) => {
|
|
|
36
36
|
}
|
|
37
37
|
diff_sequence(sequence, sa, sb, diffs);
|
|
38
38
|
}
|
|
39
|
+
const all_enums = new Set([...Object.keys(a.enums), ...Object.keys(b.enums)]);
|
|
40
|
+
for (const enum_name of [...all_enums].sort()) {
|
|
41
|
+
const ea = a.enums[enum_name];
|
|
42
|
+
const eb = b.enums[enum_name];
|
|
43
|
+
if (!ea) {
|
|
44
|
+
diffs.push({ kind: 'enum_only_in', where: 'b', enum_name });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!eb) {
|
|
48
|
+
diffs.push({ kind: 'enum_only_in', where: 'a', enum_name });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
diff_enum(enum_name, ea, eb, diffs);
|
|
52
|
+
}
|
|
39
53
|
return diffs;
|
|
40
54
|
};
|
|
41
55
|
const COLUMN_FIELDS = [
|
|
@@ -124,6 +138,14 @@ const diff_sequence = (sequence, a, b, out) => {
|
|
|
124
138
|
});
|
|
125
139
|
}
|
|
126
140
|
};
|
|
141
|
+
const diff_enum = (enum_name, a, b, out) => {
|
|
142
|
+
// Labels are an ordered set — compare positionally, so both a missing/extra
|
|
143
|
+
// label and a reorder (a real schema change) surface as drift.
|
|
144
|
+
const differ = a.labels.length !== b.labels.length || a.labels.some((l, i) => l !== b.labels[i]);
|
|
145
|
+
if (differ) {
|
|
146
|
+
out.push({ kind: 'enum_labels_differ', enum_name, a: a.labels, b: b.labels });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
127
149
|
/**
|
|
128
150
|
* Render a diff list as a human-readable multi-line string. Empty diffs
|
|
129
151
|
* produce an empty string.
|
|
@@ -164,6 +186,12 @@ export const format_schema_diffs = (diffs, labels = {}) => {
|
|
|
164
186
|
case 'sequence_data_type_differs':
|
|
165
187
|
lines.push(` sequence ${d.sequence} data_type differs: ${label_a}=${d.a}, ${label_b}=${d.b}`);
|
|
166
188
|
break;
|
|
189
|
+
case 'enum_only_in':
|
|
190
|
+
lines.push(` enum ${d.enum_name} only in ${where_label(d.where)}`);
|
|
191
|
+
break;
|
|
192
|
+
case 'enum_labels_differ':
|
|
193
|
+
lines.push(` enum ${d.enum_name} labels differ: ${label_a}=${JSON.stringify(d.a)}, ${label_b}=${JSON.stringify(d.b)}`);
|
|
194
|
+
break;
|
|
167
195
|
default:
|
|
168
196
|
// Compile-time exhaustiveness — a new SchemaDiff variant without a
|
|
169
197
|
// case here makes `d` non-never and fails type-check.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
5
|
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test": "gro test",
|
|
17
17
|
"test:cross": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_ts_node --project cross_backend_ts_deno --project cross_backend_ts_bun",
|
|
18
18
|
"test:cross:spine-stub": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_spine_stub",
|
|
19
|
+
"test:cross:schema-parity": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_schema_parity",
|
|
19
20
|
"benchmark:cross-impl": "gro run src/benchmarks/cross_impl.bench.ts",
|
|
20
21
|
"preview": "vite preview",
|
|
21
22
|
"deploy": "gro deploy"
|