diff --git a/.agent/repo=.this/role=any/boot.yml b/.agent/repo=.this/role=any/boot.yml index e2b7733..bc89bd4 100644 --- a/.agent/repo=.this/role=any/boot.yml +++ b/.agent/repo=.this/role=any/boot.yml @@ -1,4 +1,7 @@ always: + briefs: + say: + - briefs/practices/behavior.verification/rule.require.acceptance-full-roundtrip.md skills: say: - skills/use.testdb.sh diff --git a/.agent/repo=.this/role=any/briefs/practices/behavior.verification/rule.require.acceptance-full-roundtrip.md b/.agent/repo=.this/role=any/briefs/practices/behavior.verification/rule.require.acceptance-full-roundtrip.md new file mode 100644 index 0000000..e2e902b --- /dev/null +++ b/.agent/repo=.this/role=any/briefs/practices/behavior.verification/rule.require.acceptance-full-roundtrip.md @@ -0,0 +1,114 @@ +# tldr + +## severity: blocker + +acceptance tests must **fully round-trip** the deliverable — drive real input through the +real system and read the real result back — not merely assert on static output text. + +a test that stops at "the tool produced this text" proves the artifact was *authored*, never +that it *works*. the round-trip proves it works: apply what was produced, exercise it, and +read the effect back through the same public surface a caller would use. + +--- +--- +--- + +# deets + +## .what + +an acceptance test proves a promise to a real caller. to earn that proof it must complete the +whole loop the caller completes: + +1. **produce** — invoke the contract (run the CLI, call the endpoint, build the artifact) +2. **apply** — put the produced artifact into the real system it targets (apply the generated + schema to a real postgres, deploy the built config, load the generated module) +3. **exercise** — drive real input through it (call the generated function, hit the endpoint, + run the generated code) +4. **read back** — observe the real effect through the same public surface a caller reads + (query the generated view, read the response body, inspect the returned value) +5. **assert + snapshot** — assert the round-tripped result equals what was put in, and snapshot + it so a reviewer sees the real caller experience in a diff + +a test that performs only steps 1 and 5 on the *text* of the produced artifact (e.g. reads a +generated `.sql` file and greps it for a column type) is a **static-output assertion**, not an +acceptance test. it is welcome as an extra check, but it does not satisfy this rule on its own. + +## .why + +- **authored is not the same as works.** generated SQL can read perfectly and still fail to + apply (a reserved word, a bad type, a broken constraint). only a real apply + execute proves + it runs. +- **the caller lives at the far end of the loop.** the promise is "you can use this," not "this + text exists." the round-trip is the only test that stands where the caller stands. +- **drift hides in the gap.** a generator can drift such that its output text still matches an + old snapshot yet no longer executes. the round-trip catches what a text snapshot cannot. +- **the effect is the truth.** the returned row, the response body, the read-back value — that + is the deliverable. assert on the effect, not on the recipe that was meant to cause it. + +## severity: blocker + +an acceptance test that only asserts on static output text, with no real apply + execute + +read-back, gives false confidence: it turns green while the deliverable is broken for every +caller. that is the exact failure this gate exists to prevent, so an acceptance suite without a +full round-trip for each contract is a blocker. + +## .where + +- every `*.acceptance.test.ts` that covers a contract which produces an artifact meant to run + or be applied (codegen output, schema DDL, built config, generated client) +- the round-trip uses the real dependency (a real postgres via `rhx use.testdb`), never a mock + — consistent with `rule.forbid.acceptance.mocks` + +## .when + +- applies whenever the contract's output is *executable* or *applyable* (SQL, code, config) +- does **not** demand a round-trip for a contract whose output is purely terminal text with no + downstream system (e.g. a `--help` screen) — there, the stdout snapshot IS the full effect +- when a specific artifact cannot itself be applied (e.g. it transitively emits a reserved + identifier the tool does not quote), round-trip an equivalent self-contained artifact that + exercises the identical code path, and record why the original could not serve + +## .how + +- provision the real dependency in setup (`rhx use.testdb`, or the CI `start:testdb` step) +- apply the LITERAL produced artifact (read the generated file and run it), so the test proves + the on-disk output, not an in-memory re-derivation +- drive input through the produced surface, read the result back through the produced surface +- assert the read-back equals the input, then snapshot a stabilized view of it (strip volatile + db-generated keys) so the caller experience is legible in a PR diff +- cover the mutation lifecycle where one exists: write, re-write unchanged (no-op), change + (one effect) — so idempotency and change-detection are proven, not assumed + +## .examples + +### positive + +```ts +// produce -> apply -> exercise -> read back -> assert + snapshot +execSync('./bin/run generate -c config.yml'); // produce +await db.query({ sql: readGenerated('tables/parcel.sql') }); // apply the literal output +await db.query({ sql: readGenerated('functions/upsert_parcel.sql') }); +await db.query({ sql: readGenerated('views/view_parcel_current.sql') }); +const id = await upsertParcel({ tags: ['a', 'b'], land_use: ['RESIDENTIAL'] }); // exercise +const row = await db.query({ sql: `select * from view_parcel_current where id = ${id}` }); // read back +expect(row.tags).toEqual(['a', 'b']); // assert the effect +expect(asStableRow(row)).toMatchSnapshot(); // snapshot the caller experience +``` + +### negative + +```ts +// static-output assertion only: proves the text was authored, never that it runs +execSync('./bin/run generate -c config.yml'); +const sql = readGenerated('functions/upsert_parcel.sql'); +expect(sql).toContain('in_tags varchar[]'); // the recipe reads right... +expect(sql).toMatchSnapshot(); // ...but no apply or execute ever ran +``` + +## .see also + +- `rule.forbid.acceptance.mocks` — the round-trip uses the real dependency, never a mock +- `rule.require.acceptance.blackbox` — the round-trip drives + reads through the public surface +- `rule.require.acceptance-journey-coverage` — the journeys a round-trip must cover +- `skills/use.testdb.sh` — provisions the real postgres the round-trip needs diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/.bind/beav.native-primitive-enum-array-columns.flag b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.bind/beav.native-primitive-enum-array-columns.flag new file mode 100644 index 0000000..1031788 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.bind/beav.native-primitive-enum-array-columns.flag @@ -0,0 +1,2 @@ +branch: beav/native-primitive-enum-array-columns +bound_by: init.behavior skill diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/.gitignore b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/.gitignore new file mode 100644 index 0000000..819676d --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/.gitignore @@ -0,0 +1,3 @@ +# ignore all peer-review files +* +!.gitignore diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.bind.beav.native-primitive-enum-array-columns.flag b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.bind.beav.native-primitive-enum-array-columns.flag new file mode 100644 index 0000000..61271e8 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.bind.beav.native-primitive-enum-array-columns.flag @@ -0,0 +1,2 @@ +branch: beav/native-primitive-enum-array-columns +bound_by: route.bind skill diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.gitignore b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.gitignore new file mode 100644 index 0000000..62a8c8b --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/.gitignore @@ -0,0 +1,5 @@ +# ignore all except passage.jsonl and .bind flags +* +!.gitignore +!passage.jsonl +!.bind.* diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/passage.jsonl b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/passage.jsonl new file mode 100644 index 0000000..993cb95 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/.route/passage.jsonl @@ -0,0 +1,101 @@ +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-grounded-in-reality"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-grounded-in-reality"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-requirements"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-assumptions"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-questions"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"approval","reason":"wait for human approval"} +{"stone":"1.vision","status":"approved"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"passed"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-yagni"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-pruned-yagni"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-pruned-backcompat"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-consistent-mechanisms"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-consistent-conventions"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-consistent-conventions"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: behavior-declaration-coverage"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: behavior-declaration-adherance"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: role-standards-adherance"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: role-standards-coverage"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (15 > 0)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (1 > 0)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"no review files found for hash 92f335a3"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"no review files found for hash 3662c60a"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"no review files found for hash 3ac7771a"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (1 > 0)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"exhausted","reason":"peer reviewer budget exhausted: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"nitpicks exceed threshold (4 > 3)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"exhausted","reason":"peer reviewer budget exhausted: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (1 > 0)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"nitpicks exceed threshold (4 > 3)"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"passed"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.self","reason":"review.self required: has-behavior-coverage"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-behavior-coverage"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-zero-test-skips"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-all-tests-passed"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-preserved-test-intentions"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-snap-changes-rationalized"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-critical-paths-frictionless"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-ergonomics-validated"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-fixed-all-gaps"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (2 > 0)"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer.uncontemplated","reason":"peer review awaits contemplation: enroll-verif-snapshot-blemishes"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer.uncontemplated","reason":"peer review awaits contemplation: enroll-verif-snapshot-blemishes"} +{"stone":"5.3.verification","status":"contemplated","reason":"contemplated review.peer: enroll-verif-snapshot-blemishes"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"malfunction"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (10 > 0)"} +{"stone":"5.3.verification","status":"contemplated","reason":"contemplated review.peer: enroll-verif-snapshot-coverage"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"passed"} +{"stone":"5.3.verification","status":"rewound"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.self","reason":"review.self required: has-behavior-coverage"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-behavior-coverage"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-zero-test-skips"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-all-tests-passed"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-preserved-test-intentions"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-snap-changes-rationalized"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-critical-paths-frictionless"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-ergonomics-validated"} +{"stone":"5.3.verification","status":"promised","reason":"promised review.self: has-fixed-all-gaps"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (10 > 0)"} +{"stone":"5.3.verification","status":"contemplated","reason":"contemplated review.peer: enroll-verif-snapshot-coverage"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"passed"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"passed"} +{"stone":"5.3.verification","status":"passed"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (4 > 0)"} +{"stone":"5.3.verification","status":"contemplated","reason":"contemplated review.peer: mech-test-scope-purity"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"passed"} +{"stone":"5.3.verification","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.3.verification","status":"passed"} diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md new file mode 100644 index 0000000..291d806 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md @@ -0,0 +1,187 @@ +wish = + +# wish: sql-schema-generator — native primitive + enum array columns + +## .what (one sentence) + +teach `sql-schema-generator` to accept `prop.ARRAY_OF()` and +`prop.ARRAY_OF(prop.ENUM([...]))` and emit a **native postgres array column** +(`text[]`, `numeric[]`, `boolean[]`, `timestamptz[]`, `[]`) — not a join table. + +## .source (authoritative) + +this is a downstream handoff authored by the `sql-dao-generator` mechanic with full +source context. the authoritative write-up lives in that repo's behavior dir: + +- `ehmpathy/sql-dao-generator` → + `.behavior/v2026_07_20.consume-primitive-array-classification/handoff.sql-schema-generator.md` +- consumer wish + vision: same behavior dir (`0.wish.md`, `1.vision.yield.md`) +- consumer emission (unit-proven): + `sql-dao-generator/src/domain.operations/define/sqlSchemaGenerator/defineSqlSchemaGeneratorCodeForProperty.ts` + its `.test.ts` + +## .why + +`sql-dao-generator` learned to classify the three array kinds and emit the right +`prop.*` per kind: + +| domain type | sql-dao-generator now emits | +| --- | --- | +| `tags: string[]` | `prop.ARRAY_OF(prop.VARCHAR())` | +| `readings: number[]` | `prop.ARRAY_OF(prop.NUMERIC())` | +| `flags: boolean[]` | `prop.ARRAY_OF(prop.BOOLEAN())` | +| `momentsAt: Date[]` | `prop.ARRAY_OF(prop.TIMESTAMPTZ())` | +| `statuses: Status[]` | `prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'PAUSED']))` | +| `zones: ZoneRef[]` | `prop.ARRAY_OF(prop.REFERENCES(zone))` (unchanged — relation) | +| `photoUuids: string[]` | `prop.ARRAY_OF(prop.UUID())` (unchanged) | + +that emission is complete, unit-proven, and green. but the emitted declaration is +INERT downstream: `sql-schema-generator@0.25.3` throws on every non-reference, +non-uuid element. + +## .the current blocker (verified in source) + +`sql-schema-generator/src/domain.operations/define/defineProperty.ts:367` (same at +published 0.25.3 and at HEAD): + +```ts +export const ARRAY_OF = (property: Property) => { + const isArrayOfReferences = !!property.references; + const isArrayOfUuids = serialize(property) === serialize(UUID()); + if (!isArrayOfReferences && !isArrayOfUuids) + throw new Error('only arrays of REFERENCEs or UUIDs are supported'); + return new Property({ ...property, array: true }); +}; +``` + +two facts this reveals: + +1. **`ARRAY_OF` accepts only `REFERENCES(...)` or `UUID()`.** + `VARCHAR/NUMERIC/BOOLEAN/TIMESTAMPTZ/ENUM` all fail both checks and throw. +2. **even the cases that pass do NOT emit a native array column.** the `array: true` + flag drives a **join table** (a child map-table plus a BINARY(32) column). proof — + a `_uuids` array today generates a child table (`train_engineer_version_to_license_uuid` + with an `array_order_index` column), not a `uuid[]` column. + +so there is no native-array path anywhere in the current schema generator — the whole +array model is join-table-based. what the wish wants (`tags text[]` on the base row) +is a new capability. + +## .the contract i need + +### 1. `ARRAY_OF` accepts primitive + enum element props + +extend `ARRAY_OF` to accept these element props (in addition to the current +`REFERENCES` / `UUID`): `VARCHAR`, `NUMERIC`, `BOOLEAN`, `TIMESTAMPTZ`, `ENUM([...])`. + +### 2. primitive/enum arrays → native array column on the base table + +a primitive or enum array prop must produce a single native postgres array column on +the base (and version, if updatable) table — NOT a join table: + +| element prop | native column type | +| --- | --- | +| `VARCHAR()` | `text[]` (or `varchar[]`) | +| `NUMERIC()` | `numeric[]` | +| `BOOLEAN()` | `boolean[]` | +| `TIMESTAMPTZ()` | `timestamptz[]` | +| `ENUM([...])` | `[]` (reuse the enum type the solo `ENUM` path defines) | + +reference arrays (`ARRAY_OF(REFERENCES(...))`) and uuid arrays (`ARRAY_OF(UUID())`) +keep their current join-table behavior — this change is additive and must not regress +them. + +### 3. round-trip through the generated machinery + +the native array column must flow through the pieces a consumer relies on end-to-end: + +- **DDL**: `CREATE TABLE ... (tags text[], ...)` on the base table (and version table + when the prop is updatable). +- **upsert function** (`upsert_*.sql`): accept the array as a single argument and write + it to the column (no per-element child-row insert). +- **hydrated view**: select the array column straight through (no `array_agg` join + collapse for the native-array case). +- **uniqueness / CVP**: the current-version-pointer + change-detection machinery must + treat the array column as a normal scalar-ish column for value comparison (postgres + array equality), so an unchanged array does not spuriously bump the version. + +## .open question for the implementer — native array vs join-table array + +before you build native arrays, we want your read on whether native arrays are the +right storage, or whether the join-table model you already have is better kept. + +the key enabler: the generated **hydrated view** abstracts the storage away. whether +`tags` lives as a native `text[]` column or as a `sensor_version_to_tag` child table +collapsed via `array_agg(... order by array_order_index)`, the view can present an +identical `text[]` shape to the DAO. so the DAO layer does not care which you pick — +the view closes the difference. that makes this a two-way door. + +| dimension | native `text[]` column | join-table array (current model) | +| --- | --- | --- | +| read (small list) | single row, no join | view does `array_agg` join | +| element-level mutation | whole-value rewrite | per-row insert/delete | +| element-level FK / uniqueness | none | natural (per element) | +| membership query (`x = ANY(...)`) | needs GIN index | standard index on child | +| array size / bloat | postgres row-size limits | unbounded | +| element order | native | via `array_order_index` | +| generator machinery | new column path | already built | + +concrete questions: + +1. is there a downside to native arrays that the join-table model avoids and would + bite a consumer later (bloat, mutation cost, index limits, migration pain)? +2. since the view can present either as `text[]`, would you rather: (a) emit native + array columns for primitive/enum arrays (what this handoff specs), or (b) keep + join-table storage but widen the child element type to primitive/enum and let the + view collapse it, or (c) support both via a per-prop knob + (`ARRAY_OF(..., { storage: 'native' | 'relation' })`)? + +our default preference is native arrays (the wisher settled on that for small, +read-mostly primitive lists), but if you see a real hazard, (b) with the view as the +seam is equally acceptable — the DAO output is identical either way. tell us which you +would rather own. + +## .acceptance (what proves it done) + +a schema declaration like: + +```ts +export const sensor: Entity = new Entity({ + name: 'sensor', + properties: { + serialNumber: prop.VARCHAR(), + tags: { ...prop.ARRAY_OF(prop.VARCHAR()), updatable: true }, + readings: { ...prop.ARRAY_OF(prop.NUMERIC()), updatable: true }, + statuses: { ...prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'FAULTED', 'OFFLINE'])), updatable: true }, + }, + unique: ['serialNumber'], +}); +``` + +should (storage per the open question — native column OR view-collapsed join table; +the DAO-visible contract is identical either way): + +1. accept the declaration without a throw (the `ARRAY_OF()` / + `ARRAY_OF(ENUM)` block is lifted). +2. apply cleanly to a real postgres (via `sql-schema-control apply`). +3. the hydrated view presents each as its element array (`text[]` / `numeric[]` / + `boolean[]` / `timestamptz[]` / `[]`) — the shape the generated DAO cast reads. +4. round-trip: upsert `{ tags: ['a','b'], readings: [1,2], statuses: ['ACTIVE'] }`, read + it back equal; re-upsert the same values → no version bump; upsert changed values → + one new version. + +## .scope notes + +- decision already settled with the wisher: native array columns, NOT a serialized + `TEXT`/json column, and NOT a join table. the join-table path is correct for + reference arrays (element-level FKs), but wrong for small read-mostly primitive/enum + lists — the case this serves. +- upstream twin: this mirrors the `domain-objects-metadata` #24 dependency the same + wish already consumed (the guard family that classifies the three array kinds). that + half shipped (v0.7.9+); this is the second, still-open half. +- once this ships (published version bumped), `sql-dao-generator` un-defers its own + e2e step (a `Sensor` fixture + live round-trip) — already scoped on that side. + +## .env + +- `ehmpathy/sql-schema-generator` (blocker at `defineProperty.ts:367`, published 0.25.3) +- consumer: `ehmpathy/sql-dao-generator` (wish: consume-primitive-array-classification) diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.guard b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.guard new file mode 100644 index 0000000..b06b635 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.guard @@ -0,0 +1,93 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/1.vision.guard.light + +# guard for vision stone +# +# requires human approval before stone can be marked as passed +# because the self-review prompts require human feedback, +# the process needs to halt here for human review + +judges: + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism approved? --stone $stone --route $route + +reviews: + self: + - slug: has-grounded-in-reality + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + did the junior ground the vision in reality, or make things up? + + check the groundwork section: + + for external references (APIs, services, docs): + - did they actually verify these exist? + - did they cite what they checked? + - or did they assume without sanity check? + + for internal references (extant behavior, patterns, code): + - did they actually verify what that behavior is? + - did they verify extant contracts to conform to? (interfaces, types, signatures) + - did they verify extant vocab to reuse? (domain terms, name patterns) + - did they verify extant stdouts to match? (CLI output patterns, error formats) + - did they cite specific files/lines? + - or did they assume the code works a certain way without verification? + + this is NOT about exhaustive research — just sanity checks. + the question is: is this vision coherent with reality, or built on assumptions? + + - slug: has-questioned-requirements + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any requirements that should be questioned? + + for each requirement, ask: + - who said this was needed? when? why? + - what evidence supports this requirement? + - what if we didn't do this — what would happen? + - is the scope too large, too small, or misdirected? + - could we achieve the goal in a simpler way? + + challenge each requirement and justify why it belongs. + + - slug: has-questioned-assumptions + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any hidden assumptions the junior took as requirements? + + for each assumption, ask: + - what do we assume here without evidence? + - what evidence supports this assumption? + - what if the opposite were true? + - did the wisher actually say this, or did we infer it? + - what exceptions or counterexamples exist? + + surface all hidden assumptions and question each one. + + - slug: has-questioned-questions + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any open questions? triage them: + + for each question, ask: + - can this be answered via logic now? if so, answer it now. + - can this be answered via extant docs or code now? if so, answer it now. + - should this be answered via external research later? if so, mark it for research. + - does only the wisher know the answer? if so, ask the wisher. + + for each question, ensure it is clearly marked as either: + - [answered] — resolved now + - [research] — to be answered in the research phase + - [wisher] — requires wisher input + + ensure they're enumerated within the vision under "open questions & assumptions" + + peer: [] diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.stone b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.stone new file mode 100644 index 0000000..6e1c076 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.stone @@ -0,0 +1,70 @@ +illustrate the vision implied in the wish .behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md + +emit into .behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.yield.md + +--- + +paint a picture of what the world looks like when this wish is fulfilled + +testdrive the contract we propose via realworld examples + +specifically, + +## the outcome world + +- what does a day-in-the-life look like with this in place? +- what's the before/after contrast? +- what's the "aha" moment where the value clicks? + +## user experience + +- what usecases do folks fulfill? what goals? +- what contract inputs & outputs do they leverage? +- what would it look like to leverage them? +- what timelines do they go through? + +## mental model + +- how would users describe this to a friend? +- what analogies or metaphors fit? +- what terms would they use vs what terms would we use? + +## evaluation + +- how well does it solve the goals? +- what are the pros? the cons? +- what edgecases exist and how do our contracts keep users in a pit of success? + +## open questions & assumptions + +- what assumptions have we made? +- what questions remain unanswered? +- what must we validate with the wisher before we proceed? +- what must we research externally? + +## groundwork + +sanity check the vision against reality. NOT exhaustive research — just enough to know the vision isn't built on false assumptions. + +### external research + +if the wish references external APIs, services, or docs: +- did you verify they exist and work the way you assume? +- cite what you checked (links, key constraints noted) +- if none referenced, say "none — no external dependencies" + +### internal research + +if the wish references extant behavior, patterns, or code: +- did you verify what that behavior actually is? +- contracts: interfaces, types, signatures to conform to +- vocab: domain terms, name patterns to reuse +- stdouts: CLI output patterns, error formats to match +- cite what you checked (file paths, line numbers) +- if none referenced, say "none — no extant behavior extended" + +## what is awkward? + +- what feels off or forced? +- where does the design fight the user's mental model? +- what tradeoffs feel uncomfortable? diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.yield.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.yield.md new file mode 100644 index 0000000..7053091 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.yield.md @@ -0,0 +1,463 @@ +# 1. vision — native primitive + enum array columns + +## .what + +teach `sql-schema-generator` to accept `prop.ARRAY_OF()` and +`prop.ARRAY_OF(prop.ENUM([...]))` and emit a **native postgres array column** +(`text[]`, `numeric[]`, `boolean[]`, `timestamptz[]`, `[]`) on the base (and +version) table — not a join table. reference and uuid arrays keep their current +join-table behavior, unchanged. + +--- + +## the outcome world + +### day-in-the-life + +a mechanic downstream (`sql-dao-generator`) has a domain object with a small, +read-mostly list: + +```ts +export const sensor: Entity = new Entity({ + name: 'sensor', + properties: { + serialNumber: prop.VARCHAR(), + tags: { ...prop.ARRAY_OF(prop.VARCHAR()), updatable: true }, + readings: { ...prop.ARRAY_OF(prop.NUMERIC()), updatable: true }, + statuses: { ...prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'FAULTED', 'OFFLINE'])), updatable: true }, + }, + unique: ['serialNumber'], +}); +``` + +they run the generator. it produces DDL where `tags`, `readings`, and `statuses` +are **columns on `sensor_version`**, typed `text[]`, `numeric[]`, and `varchar[]` +(with an array-membership check constraint). no `sensor_version_to_tag` child table. +the upsert function takes `in_tags text[]` and writes it in one shot. the hydrated +view selects the array column straight through. + +### before / after + +| dimension | before (0.25.3) | after (this wish) | +| --- | --- | --- | +| `ARRAY_OF(prop.VARCHAR())` | **throws** at declare time | emits `text[]` column | +| `ARRAY_OF(prop.ENUM([...]))` | **throws** at declare time | emits `varchar[]` + array-membership check | +| storage for a 3-tag list | (impossible) | 3 values in one row cell | +| upsert of `['a','b']` | (impossible) | one arg, one write | +| view read | (impossible) | `SELECT tags` — no `array_agg` join | +| `ARRAY_OF(REFERENCES(x))` | join table | join table (unchanged) | +| `ARRAY_OF(UUID())` | join table | join table (unchanged) | + +### the "aha" moment + +the value clicks when a consumer sees the generated `sensor_version` DDL and finds +`tags text[]` sitting **inline on the row** — no child table, no `array_order_index`, +no `array_agg` in the view. the list reads and writes as a single value, exactly the +shape the DAO already expects. a small primitive list stops paying the price of a +full n:m relation. + +--- + +## user experience + +### usecases fulfilled + +- **store a small primitive list** (tags, readings, flags, moments) as a first-class + column, without provisioning a relation. +- **store a small enum list** (statuses, roles) with element-level validation via a + check constraint. +- **round-trip that list** through the generated upsert + view + CVP so an unchanged + list does not spuriously bump the version, and a changed list makes exactly one new + version. + +### contract inputs & outputs + +**input** (the declaration surface — additive to `prop`): + +| the caller writes | the generator emits (base/version column) | +| --- | --- | +| `prop.ARRAY_OF(prop.VARCHAR())` | `text[]` (or `varchar[]`) | +| `prop.ARRAY_OF(prop.NUMERIC())` | `numeric[]` | +| `prop.ARRAY_OF(prop.BOOLEAN())` | `boolean[]` | +| `prop.ARRAY_OF(prop.TIMESTAMPTZ())` | `timestamptz[]` | +| `prop.ARRAY_OF(prop.ENUM([...]))` | `varchar[]` + array-membership check | +| `prop.ARRAY_OF(prop.REFERENCES(x))` | join table (unchanged) | +| `prop.ARRAY_OF(prop.UUID())` | join table (unchanged) | + +**outputs** (the four generated artifacts): + +1. **DDL** — `CREATE TABLE sensor_version (..., tags text[], readings numeric[], ...)`. +2. **upsert fn** — `upsert_sensor(..., in_tags text[], ...)` writes the array to the + column directly (no per-element loop). +3. **hydrated view** — `view_sensor_current` selects `tags` straight through. +4. **CVP / change-detection** — compares the array by value so re-upsert of the same + list is a no-op. + +### a timeline they go through + +``` +declare Entity with prop.ARRAY_OF(prop.VARCHAR()) + → generate schema → DDL + upsert + view, no throw + → sql-schema-control apply → tables created in real postgres + → upsert { tags: ['a','b'] } + → read view → tags = ['a','b'] + → re-upsert { tags: ['a','b'] } → no new version + → upsert { tags: ['a','c'] } → exactly one new version +``` + +--- + +## mental model + +### how a user describes it to a friend + +> "if you have a little list of plain values — tags, statuses, numbers — you just say +> `ARRAY_OF(VARCHAR())` and it becomes a `text[]` column right on the row. no separate +> table. for lists of *other records* it still makes a proper join table, because those +> need real foreign keys." + +### analogies / metaphors + +- **a shelf vs a warehouse.** a native array is a shelf inside the row — a few items, + right there. a join table is a warehouse — its own building, its own address, for + things that need to be referenced and inventoried individually. +- **a two-way door** (from the wish): the hydrated view abstracts storage away. whether + `tags` lives as a `text[]` column or as a child table collapsed with `array_agg`, the + view can present an identical `text[]` to the DAO. so the storage choice is reversible + behind the view. + +### their terms vs our terms + +| user says | we say | +| --- | --- | +| "a list column" | native postgres array column (`text[]`) | +| "a lookup list" / "a set of statuses" | enum array with an array-membership check | +| "a list of other records" | reference array → join / mapping table | +| "did it change?" | value comparison feeding the current-version-pointer (CVP) | + +--- + +## evaluation + +### how well it solves the goals + +- **primary goal — lift the throw + emit native arrays**: directly met. the declaration + is accepted and the four artifacts round-trip. +- **additive, no regression**: reference and uuid arrays are explicitly untouched — they + keep the join-table path. the change branches on element kind (primitive/enum → native, + reference/uuid → relation). + +### pros + +- native arrays are cheap to read (single row, no join) and cheap to write (one value) + for small read-mostly lists — the exact case this serves. +- the native column TYPE is nearly free to reach: the type-string machinery already + appends `[]` (`extractDataTypeDefinitionFromProperty.ts:16-17`); the base/version path + just needs to stop the swap of array props into `_hash` columns and let them flow + through. **this is only the DDL type** — the upsert, view, and CVP seams each carry + their own `if (property.array)` branch that routes to the join-table machinery today, so + each needs a new native sub-branch (enumerated in groundwork). the feature is ~5 seams, + not one line; the "nearly free" applies to the column type alone. +- element order is native (no `array_order_index` bookkeeping) for the native case. + +### cons / tradeoffs + +- **element-level mutation is a whole-value rewrite** — fine for small lists, wrong for + large or churny ones. this is the storage tradeoff the wisher already accepted for the + small-list case. +- **no element-level FK or per-element uniqueness** — native arrays cannot enforce those, + which is exactly why references stay on the join-table path. +- **membership queries** (`x = ANY(tags)`) want a GIN index the generator does not emit + today — out of scope here, but worth naming. + +### edgecases & pit-of-success + +- **empty list vs null** — an unset array should read as `[]` or `null` + consistently; the view and upsert must agree. the join-table view already + `coalesce(..., array[]::type)` to an empty array — the native path should match that + convention so the DAO sees one shape. +- **enum array validation** — the check must reject a list containing an invalid value, + not just a scalar. the pit of success is that an out-of-set element fails loud at write + time (see "what is awkward"). +- **unchanged-array re-upsert** — must not bump the version. postgres array equality + (`IS NOT DISTINCT FROM`) handles null-vs-empty-vs-value cleanly if we compare by value + rather than by the sha256 hash. +- **mixed entity** — an entity with both a primitive array (native) and a reference array + (join table) must generate both paths side by side without collision. + +--- + +## open questions & assumptions + +### assumptions made + +1. **native arrays are the chosen storage** for primitive/enum arrays (the wisher settled + this; the wish states it as decided). the join-table path is retained only for + reference/uuid arrays. +2. **enum arrays stay `varchar[]` + check constraint**, consistent with the solo-enum + convention (no native pg `CREATE TYPE` enum), just widened to validate every element. +3. **change-detection compares by value** (postgres array equality) for the native case, + per the wish's CVP requirement — replacing (for native arrays) the sha256-hash column + the join-table path uses. see the fulcrum below. + +### open questions — triaged + +each question is tagged `[answered]` (settled now by logic/code), `[research]` (prove at +execution), or `[wisher]` (needs wisher input). best-guesses stand so the drive continues. + +- `[wisher]` **the wish's own open question** (native vs join-table vs per-prop knob): our + read is **(a) native array columns** for primitive/enum arrays, matching the stated + default and the small read-mostly use case. no hazard forces (b) or the (c) knob for this + scope. best-guess (a) stands; flagged for confirmation, not blocking. +- `[answered]` **change-detection seam** — decided now by logic; it is a contained fork: + - **option A (value equality):** compare the native array column directly + (`col IS NOT DISTINCT FROM in_val`). cleanest, matches the wish's "postgres array + equality" language, no extra column. **chosen.** + - **option B (reuse hash):** keep a `${name}_hash` column beside the native array and let + the current CVP machinery key off it unchanged. lower blast-radius, but a redundant + column + duplicated state. + decision: **option A**. if a later reviewer prefers B for a smaller diff, it is a + contained rework (the comparison expression is one seam). +- `[answered]` **`text[]` vs `varchar[]`** for the VARCHAR element — decided now: follow + the element prop's own type name (VARCHAR → `varchar[]`) for consistency with the solo + path. the wish accepts either; trivially swappable. +- `[research]` **enum-array check SQL semantics** — the proposed + `$COLUMN_NAME <@ ARRAY['A','B']::varchar[]` must be proven at execution against a real + postgres: specifically the NULL-array and empty-array cases (a NULL column passes a CHECK + by default, possibly a hole → may need `col IS NULL OR col <@ ...`). answerable only by + running it; marked for the execution/verification phase. +- `[answered]` **empty vs null for an unset native array** — decided now: match the + join-table convention (present as `[]`, not `null`) via an explicit `coalesce(col, '{}')` + in the view, so the DAO sees one shape across both storage models. the wisher may override + the convention, but the default is settled. + +### external research needed + +- none beyond standard postgres. native array column types (`text[]`, `numeric[]`, + `boolean[]`, `timestamptz[]`), array equality (`=`, `IS NOT DISTINCT FROM`), and the + contained-by operator (`<@`) for element-membership checks are all long-established + core postgres features. no external API/service dependency in this wish. + +--- + +## groundwork + +### external research + +**none — no external dependencies.** the only external surface is postgres itself, and +the features relied on (native array column types, array `=` / `IS NOT DISTINCT FROM`, +`<@` contained-by for the enum-array check, optional GIN for membership) are core, +stable postgres capabilities. `sql-schema-control apply` is the extant downstream applier +already used by the join-table path; no new behavior is assumed of it. + +### internal research + +verified against source at HEAD (paths relative to repo root): + +- **the blocker is real and exactly as the wish states** — + `src/domain.operations/define/defineProperty.ts:367-373`: `ARRAY_OF` throws unless the + element is a `REFERENCES` or a `UUID()`. +- **the current array model is entirely join-table** — + - base/version tables swap each array prop for a `${name}_hash` BYTEA column: + `src/domain.operations/generate/entityTables/utils/castArrayPropertiesToValuesHashProperties.ts`, + wired in `generateTableForStaticProperties.ts:37-39` and + `generateTableForUpdateableProperties.ts:42-44`. + - a separate mapping table with an `array_order_index` SMALLINT is generated per array + prop: `src/domain.operations/generate/entityTables/generateMappingTablesForArrayProperties.ts`. + - even a `UUID()` array produces a child table today — there is **no** native-array + column path anywhere yet. native arrays are a new capability, as the wish says. +- **the native type-string is already latent** — + `src/domain.operations/generate/utils/extractDataTypeDefinitionFromProperty.ts:16-17` + already appends `[]` when `property.array` is true. this generator just is not reached + for base/version columns because arrays are swapped for `_hash` columns first. lifting + the native case largely means letting primitive/enum arrays flow through as real + columns instead of being hashed away. +- **change-detection uses a sha256 hash** — + `src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.ts:23-24` + hashes `array_to_string(in_x, ',', '__NULL__')` and compares against `${name}_hash`. + the native path needs a value-comparison seam here instead (option A above). +- **upsert per-element inserts** — + `src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineMappingTableInsertsForArrayProperty.ts` + loops `1..array_upper(...)` inserting one child row per element. the native path + replaces this loop with a single column write. +- **view collapses via `array_agg`** — + `src/domain.operations/generate/entityViews/utils/castPropertyToSelector.ts:14-32` + builds a `coalesce(array_agg(... ORDER BY array_order_index), array[]::type)` subquery. + the native path selects the column directly and keeps the empty-array coalesce + convention. +- **enum is VARCHAR + check, no native pg type** — + `src/domain.operations/define/defineProperty.ts:205-213` emits + `($COLUMN_NAME IN ('A','B'))`; substitution happens in + `generateTable.ts:57-63` (`.replace(/\$COLUMN_NAME/g, columnName)`). for a `[]` + column the `IN` form does not apply to an array — see "what is awkward." +- **test pattern** — unit `.test.ts` with mocks for the pure generators; integration + `.integration.test.ts` against a real testdb (`npm run start:testdb`) for DDL/upsert/view + round-trips. array examples already present to mirror: + `generateEntityTables.integration.test.ts`, + `generateEntityUpsert.integration.test.ts`, + `generateEntityCurrentView.integration.test.ts`, and the end-to-end + `generateAndRecordEntitySchema.integration.test.ts`. + +--- + +## what is awkward? + +- **the enum-array check constraint is the sharpest edge.** the solo-enum check is + `($COLUMN_NAME IN ('A','B'))`, which is a *scalar* membership test. it does not apply to + a `varchar[]` column. a `[]` needs an *every-element* membership test — e.g. + `$COLUMN_NAME <@ ARRAY['A','B']::varchar[]` (the column's values are contained by the + allowed set). this means the `ENUM` definer (or the array wrapper) must know to emit a + different check shape when the enum is arrayed. the `$COLUMN_NAME` substitution stays, + but the check *template* differs between solo and array enums. this is the one place the + additive change reaches into the enum definition rather than sitting cleanly beside it. + +- **two array storage models now coexist**, split by element kind. a reader of the + generator must hold "primitive/enum → native column, reference/uuid → join table" in + their head. the branch is principled (element-level FKs need a relation) but it is a new + fork in the mental model where before "array" meant exactly one thing. + +- **the change-detection code was built around the `_hash` column.** introducing a + value-comparison seam for native arrays means the CVP/where-clause machinery has two + comparison strategies. this fights the current assumption that "array ⇒ compare the + hash." it is contained (one expression), but it is a place where the design's prior + uniformity gives way. + +- **whole-value rewrite semantics may surprise a consumer** who thinks "array column" and + expects element-level ops. the view abstracts storage, not mutation cost — a caller + appending one tag rewrites the whole cell. acceptable for the small-list target, but a + latent foot-gun if someone reaches for native arrays on a large or churny list. naming + a note of the intended use (small, read-mostly) in the `ARRAY_OF` docs is the guardrail. + +--- + +## appendix: storage-model tradeoffs (researched, with citations) + +this appendix formally documents the two storage decisions the vision rests on, each +grounded in postgres authority rather than taste. sources listed at the end. (source URLs +[2] and [3] carry a gerund in their path — they are verbatim citation links, unalterable.) + +### A. the string element type — `varchar[]` vs `text[]` + +the vision emits `varchar[]` for `ARRAY_OF(prop.VARCHAR())` (and keeps `text[]` for +`ARRAY_OF(prop.TEXT())`), on the principle that `ARRAY_OF` is a pure pass-through that +flips the array flag and does NOT rewrite the element's declared type +(`defineProperty.ts:372` — `new Property({ ...property, array: true })`). + +postgres authority: the three character types differ in no meaningful way. per the docs, +"There is no performance difference among these three types, apart from … a few extra CPU +cycles to check the length" for a length-constrained column [1], and text is the canonical +native string type — "most built-in functions … take or return `text`" [1]. the docs' own +steer: "In most situations text … should be used instead" [1]. storage is dynamic for all +— only the bytes the string needs [2] — and text/varchar share one storage format, TOAST +behavior, and index support [3]. + +**pros of `varchar[]` (honor the declared element type):** +- preserves precision — `ARRAY_OF(prop.VARCHAR(255))` yields `varchar(255)[]`; a forced + `text[]` would silently discard the `255` bound the caller declared. +- keeps `ARRAY_OF` a pure pass-through — one mental model: the element prop decides the + type, the wrapper decides the arity. no hidden type rewrite. +- symmetric with the solo path — `prop.VARCHAR()` alone emits `varchar` today; the arrayed + form emits `varchar[]`. + +**cons of `varchar[]` / pros of always-`text[]`:** +- the postgres project's own steer leans text as the default [1], and the wider community + calls text the canonical choice for maintainability — pick one type and apply it + throughout [3][4]. +- a DAO could see both `varchar[]` and `text[]` across a schema, per what each field + declared — two string-array shapes rather than one canonical shape. +- there is **no** performance or storage penalty to text as the default [1][2], so the only + cost of always-text is the lost `(n)` precision bound. + +**decision:** honor the declared element type (`varchar[]` / `text[]` as written), because +it preserves the `(n)` bound and keeps `ARRAY_OF` a wrapper that does not rewrite the type. +a caller who wants the canonical text form declares `ARRAY_OF(prop.TEXT())`. this is a +`[wisher]`-overridable convention and a contained one-line rework if always-text is +preferred — the research confirms either is performance-neutral [1]. + +### B. native array column vs join table (for primitive/enum lists) + +**pros of native arrays (why they beat a join table for small, read-mostly lists):** +- **faster reads** — "It's just a lot faster to get a row from a single table than to join + multiple tables" [5]; a native array needs no join, whereas the join-table view does an + `array_agg` collapse. +- **fits the whole-value access pattern** — "If a set of values is always fetched together + and updated together, you might as well treat it as a unit and not split it over multiple + tables" [5]. the community rule of thumb: "we mostly use the array as a whole, even if we + might, at times, search for elements in the array" [6]. +- **membership stays indexable** — a GIN index on the array column makes tag/membership + queries efficient without a junction table [5] (a GIN index is out of this wish's scope, + but the path stays open). +- **no `array_order_index` overhead** — native arrays preserve element order for free. + +**cons of native arrays / pros of the join table (why references keep it):** +- **no element-level foreign key** — per postgres, "you cannot directly create a foreign + key constraint on an array column"; it is a long-established limitation, and the + sanctioned workaround is a separate table with a foreign-key constraint [7]. this is the + decisive reason `ARRAY_OF(REFERENCES(...))` stays a join table. +- **no per-element uniqueness** — a join table can constrain individual elements; an array + cannot. +- **whole-value rewrite on mutation** — an element change rewrites the whole cell; heavier + element-level work is more complex than a lookup table [6]. wrong for large or churny + lists — the community steer is that once you must join on those values across tables, move + them to a relation [5]. +- **row-size / bloat** — an unbounded array grows the base row; a join table is unbounded by + design. + +**decision:** native arrays for primitive/enum lists (the small, read-mostly case the wish +targets), join tables for reference arrays (element-level FK integrity). the split tracks +exactly the FK limitation [7] and the whole-value rule of thumb [5][6]. + +### C. should uuid arrays move to native `uuid[]` too? + +this is a genuine fork the wish did **not** fully settle — it scopes uuid arrays as +"unchanged (join table)," but a formal read is warranted because the FK argument that +justifies join tables for `REFERENCES` does **not** apply to a bare `UUID()`. + +- a `UUID()` array is a list of **opaque uuids** — it carries NO foreign-key constraint + today (the join table it generates holds a plain uuid column, not a FK to a parent). so + the decisive con of native arrays — the absent element FK [7] — does not bite a uuid + array. by the same logic that puts primitives on the native path, `uuid[]` belongs there + too. +- **pros of a move to native `uuid[]`:** one uniform array model for all non-reference + elements (primitive + enum + uuid → native; reference → join table); the same faster-read + [5] and whole-value fit [5][6]; drops the `_uuid` child table and its `array_order_index`. +- **cons / why it stays join-table in THIS wish:** + - **scope** — the wish explicitly lists `ARRAY_OF(UUID())` as "unchanged" and out of + scope; the classification handoff did not ask for it. + - **migration cost** — uuid arrays are the one array kind with real prior use (the extant + integration tests exercise `_uuids`). a move to native would change already-generated + schemas and force a data migration for any consumer that uses them today — a break, not + an additive change. + - **two-way door** — because the hydrated view presents either storage as `uuid[]`, the + move can be made later with no DAO-visible contract change. + +**decision (best-guess, flagged for the wisher):** keep uuid arrays on the join-table path +in this wish — honor the stated scope and avoid a migration that breaks extant schemas. BUT +record that native `uuid[]` is the better long-term home (the element-FK objection [7] does +not apply to bare uuids), and flag it as a clean follow-up wish. this is a `[wisher]` call: +if the wisher would rather absorb the migration now and unify all non-reference arrays under +the native path, that is a contained extension of the same branch — the native machinery +this wish builds for primitives would also accept the uuid element. + +### sources + +- [1] postgres docs — Character Types (no perf difference among the three; text is the + native string type; "In most situations text … should be used instead"): + https://www.postgresql.org/docs/current/datatype-character.html +- [2] dbvis — Postgres TEXT vs VARCHAR (dynamic storage, only the bytes the string needs): + https://www.dbvis.com/thetable/postgres-text-vs-varchar-comparing-string-data-types/ +- [3] airbyte — TEXT vs VARCHAR (same storage format, TOAST, index support; pick one for + maintainability): + https://airbyte.com/data-engineering-resources/postgres-text-vs-varchar +- [4] sqlpey — PostgreSQL String Types performance (text as the preferred default): + https://sqlpey.com/sql/postgresql-string-types-text-vs-varchar-vs-char-performance/ +- [5] postgres community — array vs separate table (single-table read speed; + fetch-together-treat-as-unit; a GIN index for membership; move to a relation once you join + across tables): + https://www.postgresql.org/message-id/7aa638e00905020033y7847632fw8e439f417c482f%40mail.gmail.com +- [6] "The Art of PostgreSQL" — Arrays (rule of thumb: use the array as a whole; heavier + element-level work is more complex than a lookup table): + https://www.educative.io/courses/the-art-of-postgresql/arrays +- [7] EDB / postgres community — array element foreign keys (a long-established limitation; + the workaround is a separate table with a foreign-key constraint): + https://www.enterprisedb.com/blog/postgresql-93-development-array-element-foreign-keys diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.guard b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.guard new file mode 100644 index 0000000..be2eeff --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.guard @@ -0,0 +1,222 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/5.1.execution.from_vision.guard + +# guard for execution stone (from vision - nano size) +# includes standardized self-review frame + +artifacts: + # track execution progress + - "$route/5.1.execution.from_vision.yield.md" + # track actual implementation in src/ + - "src/**/*" + +reviews: + self: + # 1. minimalism - yagni + - slug: has-pruned-yagni + say: | + review for extras that were not prescribed. + + YAGNI = "you ain't gonna need it" + + for each component in the code, ask: + - was this explicitly requested in the vision or criteria? + - is this the minimum viable way to satisfy the requirement? + - did we add abstraction "for future flexibility"? + - did we add features "while we're here"? + - did we optimize before we knew it was needed? + + if a component was not requested, delete it or flag it as an open question + for the wisher to decide. + + # 2. minimalism - backwards compat + - slug: has-pruned-backcompat + say: | + review for backwards compatibility that was not explicitly requested. + + for each backwards-compat concern in the code, ask: + - did the wisher explicitly say to maintain this compatibility? + - is there evidence this backwards compat is needed? + - or did we assume it "to be safe"? + + if backwards compat was not explicitly requested: + 1. flag it as an open question for the wisher + 2. eliminate it if not confirmed as required + 3. make the open question very clearly reported + + # 3. consistency - mechanisms + - slug: has-consistent-mechanisms + say: | + review for new mechanisms that duplicate extant functionality. + + unless the ask was to refactor, be consistent with extant mechanisms. + + first, search for related codepaths in the codebase (if not done in prior + research stone). look for extant utilities and patterns. + + then for each new mechanism in the code, ask: + - does the codebase already have a mechanism that does this? + - do we duplicate extant utilities or patterns? + - could we reuse an extant component instead of a new one? + + if a new mechanism duplicates extant functionality: + 1. replace with the extant mechanism + 2. or flag as an open question if unsure + + # 4. consistency - conventions + - slug: has-consistent-conventions + say: | + review for divergence from extant names and patterns. + + unless the ask was to refactor, be consistent with extant conventions. + + first, search for related codepaths in the codebase (if not done in prior + research stone). identify extant name conventions and patterns. + + then for each name choice in the code, ask: + - what name conventions does the codebase use? + - do we use a different namespace, prefix, or suffix pattern? + - do we introduce new terms when extant terms exist? + - does our structure match extant patterns? + + if we diverge from extant conventions: + 1. align with the extant convention + 2. or flag as an open question if the extant convention seems wrong + + # 5. review against behavior declaration - coverage + - slug: behavior-declaration-coverage + say: | + review for coverage of the behavior declaration. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have omitted + requirements or left features unimplemented. + + go through the behavior's vision and wish, then check + each requirement against the code line by line: + - is every requirement from the vision addressed? + - did the junior skip or forget any part of the spec? + + fix all gaps before you continue. + + # 6. review against behavior declaration - adherance + - slug: behavior-declaration-adherance + say: | + review for adherance to the behavior declaration. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have drifted + from the spec or implemented items incorrectly. + + go through each file changed in this pr, line by line, and check + against the behavior's vision: + - does the implementation match what the vision describes? + - did the junior misinterpret or deviate from the spec? + + fix all gaps before you continue. + + # 7. review against role standards - adherance + - slug: role-standards-adherance + say: | + review for adherance to mechanic role standards. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have introduced + bad practices or violated patterns that we require. + + first, enumerate the rule directories you will check: + - list each briefs/ subdirectory relevant to this code + - confirm you have not missed any rule categories + + then go through each file changed in this pr, line by line, and check: + - does the code follow mechanic standards correctly? + - are there violations of required patterns? + - did the junior introduce anti-patterns, bad practices, or deviations from our conventions? + + fix all gaps before you continue. + + # 8. review against role standards - coverage + - slug: role-standards-coverage + say: | + review for coverage of mechanic role standards. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have forgotten + best practices or omitted patterns that should be present. + + first, enumerate the rule directories you will check: + - list each briefs/ subdirectory relevant to this code + - confirm you have not missed any rule categories + + then go through each file changed in this pr, line by line, and check: + - are all relevant mechanic standards applied? + - are there patterns that should be present but are absent? + - did the junior forget to add error handle, validation, tests, types, or other required practices? + + fix all gaps before you continue. + + peer: + # --- level 1: cheap reviewers (run first, in parallel) --- + + - slug: repo-rules + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=.this/**/rule.*.md' --optional rules --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-failhides + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.{prod,test}/pitofsuccess.errors/rule.*.md' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-decode-friction + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=architect/briefs/practices/domain.operations/rule.{forbid.decode-friction-in-orchestrators,require.orchestrators-as-narrative}.md' --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.narrative/rule.{forbid.inline-decode-friction,require.named-transformers}.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/domain.operations/{define.domain-operation-grains,philosophy.transformer-orchestrator-separation.[philosophy]}.md' --diffs since-main --paths-with '**/*.{ts,sh}' --paths-without '**/*.test.ts' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- architect reviews (level 1) --- + + - slug: arch-opport-decomposition + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.prefer.decomposable-architecture.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.architecture/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.domain.operations/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-smell-scopeleaks + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.scope-leaks.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.architecture/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.domain.operations/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-hazards-maintenance + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.maintenance-hazards.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/rule.require.solve-at-cause.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.errors/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.typedefs/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.narrative/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.comments/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-hazards-behavior + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.behavior-hazards.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: behavior-intent-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.require.behavior-intent-coverage.md' --refs '$route/0.wish.md' --refs '$route/1.vision.yield.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.criteria/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-friction-hazards + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.friction-hazards.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/lang.tones/*.md.min' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- level 3: expensive reviewers (run after level 1 is terminal) --- + + - slug: enroll-impl-behavior-intent + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for omissions or divergences from the wished and envisioned behavioral intent. in particular flag any ergonomic friction that is unaddressed or friction hazards that are not clearly covered with acceptance tests and snaps. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 7 + level: 3 + + - slug: enroll-impl-arch-defects + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for architectural defects and omissions; opports to decompose for recompose, prevent scope leaks, and eliminate maintenance and behavior hazards structurally. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + +judges: + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 3 diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stamp b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stamp new file mode 100644 index 0000000..9db9e9b --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stamp @@ -0,0 +1,83 @@ +🦉 the way speaks for itself + +🗿 route.stone.set + ├─ stone = 5.1.execution.from_vision + ├─ passage = allowed + ├─ guard + │ ├─ artifacts + │ │ ├─ $route/5.1.execution.from_vision.yield.md + │ │ └─ src/**/* + │ ├─ reviews + │ │ ├─ r1: repo-rules (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r001._.given.by_peer.repo-rules.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r001._.taken.by_self.repo-rules.md + │ │ ├─ r2: mech-failhides (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r002._.given.by_peer.mech-failhides.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r002._.taken.by_self.mech-failhides.md + │ │ ├─ r3: mech-decode-friction (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r003._.given.by_peer.mech-decode-friction.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r003._.taken.by_self.mech-decode-friction.md + │ │ ├─ r4: arch-opport-decomposition (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r004._.given.by_peer.arch-opport-decomposition.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r004._.taken.by_self.arch-opport-decomposition.md + │ │ ├─ r5: arch-smell-scopeleaks (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r005._.given.by_peer.arch-smell-scopeleaks.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r005._.taken.by_self.arch-smell-scopeleaks.md + │ │ ├─ r6: arch-hazards-maintenance (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r006._.given.by_peer.arch-hazards-maintenance.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r006._.taken.by_self.arch-hazards-maintenance.md + │ │ ├─ r7: arch-hazards-behavior (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r007._.given.by_peer.arch-hazards-behavior.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r007._.taken.by_self.arch-hazards-behavior.md + │ │ ├─ r8: behavior-intent-coverage (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 1 nitpick 🟠 + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r008._.given.by_peer.behavior-intent-coverage.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r008._.taken.by_self.behavior-intent-coverage.md + │ │ ├─ r9: ergo-friction-hazards (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 1 nitpick 🟠 + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r009._.given.by_peer.ergo-friction-hazards.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i004.678b0909dec8884a1f.r009._.taken.by_self.ergo-friction-hazards.md + │ │ ├─ r10: enroll-impl-behavior-intent (l3, 7/7) + │ │ │ ├─ approved 1014.7s + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 1 nitpick 🟠 + │ │ │ ├─ tallied by reviewer@fireworks/deepseek/v4-flash + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i011.2a72b15c283164a06c.r010._.given.by_peer.enroll-impl-behavior-intent.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i011.2a72b15c283164a06c.r010._.taken.by_self.enroll-impl-behavior-intent.md + │ │ └─ r11: enroll-impl-arch-defects (l3, 3/3) + │ │ ├─ approved, cached + │ │ ├─ 0 blockers ✓ + │ │ ├─ 0 nitpicks ✓ + │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i007.d349b57ec2b6668e09.r011._.given.by_peer.enroll-impl-arch-defects.md + │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.1.execution.from_vision._.review.i007.d349b57ec2b6668e09.r011._.taken.by_self.enroll-impl-arch-defects.md + │ └─ judges + │ └─ j1: $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 3 + │ └─ finished 5.7s ✓ + │ + └─ the way continues, run + └─ rhx route.drive \ No newline at end of file diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stone b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stone new file mode 100644 index 0000000..ead30ac --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.stone @@ -0,0 +1,15 @@ +bootup your mechanic's role via `./node_modules/.bin/rhachet roles boot --repo ehmpathy --role mechanic` + +then, execute the vision directly +- .behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.md + +ref: +- .behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md + + +--- + +track your progress + +emit todos and check them off into +- .behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.yield.md diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.yield.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.yield.md new file mode 100644 index 0000000..22cdc22 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.1.execution.from_vision.yield.md @@ -0,0 +1,239 @@ +# 5.1 execution — native primitive + enum array columns + +## summary + +executed the vision. `ARRAY_OF` now accepts primitive (VARCHAR/NUMERIC/BOOLEAN/TIMESTAMPTZ) +and ENUM elements and emits a NATIVE postgres array column on the base/version table. +reference/uuid arrays keep their join-table behavior, unchanged. proven end-to-end against +a real postgres. + +## the core design + +a native array is treated as a **scalar column** in every generator seam, gated by one new +discriminator: + +- `src/domain.operations/generate/utils/isNativeArrayProperty.ts` (new) + - native = `array && !references && type.name !== UUID` + - ref/uuid checked FIRST so they are never mis-routed (the invariant surfaced in review r2) + +## todos — all done + +- [x] lift the `ARRAY_OF` throw for primitive/enum elements (`defineProperty.ts`) +- [x] recast the enum scalar `IN (...)` check into an element-membership check for arrays + (`<@ ARRAY[...]::varchar[]`), in `defineProperty.ts` +- [x] emit native array column in DDL (base + version): native arrays flow through the + "singular" bucket in `generateTableForStaticProperties.ts` + + `generateTableForUpdateableProperties.ts` (excluded from the values-hash swap) +- [x] skip join-table generation for native arrays (`generateEntityTables.ts`) +- [x] native array column name is its own name, not `_hash` (`castPropertyToColumnName.ts`) +- [x] upsert writes the array directly, not a hash (`castPropertyToTableColumnValueReference.ts`) +- [x] skip per-element join-table inserts for native arrays + (`defineFindOrCreateStaticEntityLogic.ts` + `defineInsertVersionIfDynamicDataChangedLogic.ts`) +- [x] view selects the native array straight through, not via `array_agg` + (`castPropertyToSelector.ts`); ANY array property (native or join-table) forces a view, + so a static-native-only entity still gets the null→`[]` coalesce (`generateEntityCurrentView.ts`) +- [x] change-detection: works for free — a native array compares by value via array `=` + (the extant scalar where-clause path), no special code needed +- [x] unit tests: `defineProperty.test.ts` (ARRAY_OF acceptance + enum-array check transform) +- [x] unit tests: updated the two table-generator tests — fixed their fake-array fixture to a + real join-table (uuid) array so the `_hash` assertion still holds, and added a native + array case to prove no hash swap (per rule.require.review-test-changes — preserved the + original intent, did not degrade it) +- [x] integration test: `nativeArrayColumns.integration.test.ts` — the sensor round-trip + +## open-question resolutions + +- `[research]` **enum-array check semantics** — ANSWERED at execution. `<@ ARRAY[...]::varchar[]` + works: the integration test confirms an out-of-set element is rejected with + `sensor_version_statuses_check`. NULL/empty-array pass the CHECK (postgres treats a NULL + CHECK result as satisfied), which matches the nullable contract. +- `[answered]` change-detection = postgres value equality (array `=`), no `_hash` column for + native arrays. confirmed: re-upsert identical array → no version bump; changed → exactly one. +- `[answered]` `varchar[]` for VARCHAR elements (honors the declared element type). + +## verification (all green) + +- `types` — passed +- `unit` — 164 passed, 0 failed (incl. native-array cases, self-review additions, the + peer-convergence additions below, the two `generateTable` emission-guard cases, and the + dedicated `castCheckToArrayElementMembership` test) +- `integration` — 96 passed, 0 failed (incl. the sensor round-trip, the static-native-only + `beacon` round-trip, the mixed native+join-table `gadget` round-trip, the broad-type `probe` + round-trip — which now also asserts `time[]`/`timestamp[]`/`bytea[]` on read-back, the `flux` + null↔value + NULL-element CVP tests, the enum-array NULL-element rejection, and all extant + join-table array tests — no regression) +- `acceptance` — 1 passed, 0 failed (the CLI `generate` runs against compiled dist for a fixture + that now includes a native primitive array + a native enum array on `home`; the test now also + asserts + snapshots the CLI-produced DDL/view content — `tags varchar[]` on the base table, + `amenities varchar[]` + `<@` membership check on the version table, and both arrays selected + straight through the view with the null→`[]` coalesce) +- `lint` — passed +- `format` — passed + +## peer-review convergence (i002 → fixes) + +the i002 peer round surfaced four consensus blockers across reviewers r4/r6/r7/r8/r9, plus +nitpicks. each fixed at the root, with test coverage: + +- **fragile uuid classification** (r4.1/r6.2/r7.1/r8.2) — `ARRAY_OF` reimplemented uuid detection + via `serialize(property) === serialize(UUID())`, which breaks on any extra attribute. now it + builds the arrayed candidate and delegates to `isNativeArrayProperty`, so there is ONE + classifier, not two. the `serialize` import is dropped. +- **static-native-only entity emitted no view** (r6.1/r7.3/r8.1/r9.2/r4.2-view) — the view's + has-view decision excluded native arrays, so an entity whose only special prop is a static + native array got NO view, and consumers read raw NULL instead of `[]` from the base table. + now ANY array property (native or join-table) forces a view. covered by the new `beacon` + integration test (view exists + NULL cell → `[]` via the coalesce). +- **custom check + ARRAY_OF → silent passthrough** (r6.3/r7.2/r9.1/r4-nit) — a non-enum check on + an arrayed primitive was left untouched, which yielded operator-invalid DDL that failed only at + apply time. now `castCheckToArrayElementMembership` THROWS a `UserInputError` at declare time. + covered by a `defineProperty.test.ts` case. +- **unsupported serial element types silently accepted** (r9.3) — a serial pseudo-type + (`serial`/`smallserial`/`bigserial`) has no valid postgres array form. `ARRAY_OF` now rejects + it at declare time with a `UserInputError`. covered by a `defineProperty.test.ts` case. + +nitpick fixes: +- **DRY the join-table predicate** (r4.2) — extracted `isJoinTableArrayProperty` and applied it + across the 7 seams that hand-rolled `!!property.array && !isNativeArrayProperty(...)`; new + `isJoinTableArrayProperty.test.ts`. +- **ARRAY_OF doc note** (r9.1) — now enumerates the supported primitive element types explicitly + instead of "etc.", and names the rejected serial family. + +after the i002 fixes, i003 approved 8/9 l1 reviewers; r9 (ergo-friction-hazards) raised a +final round, both addressed: +- **snapshot every error path** (r9.i003 blocker) — the three fail-fast error messages (the + enum-rejection postgres error, the serial-reject and custom-check-reject `UserInputError`s) now + `toMatchSnapshot()` alongside their `toContain` assertions, so a reviewer sees the exact + user-visible text in the PR diff. +- **mixed native + join-table entity** (r9.i003 nitpick) — the vision names native/join-table + coexistence as an edgecase that must work "without collision". new `gadget` integration test + round-trips an entity with both a native `labels text[]` column and a join-table `part_uuids` + array, which proves no column-name / check-name / join-order collision. + +i004 approved all 9 l1 reviewers; the l3 reviewer `enroll-impl-behavior-intent` raised 6 +coverage nitpicks (0 blockers), all closed: +- **type-scope claim vs coverage** — the doc note claimed 15 primitive types but only 5 were + proven. added a data-driven unit test that runs every documented primitive factory through + `ARRAY_OF`, plus a `probe` integration entity that proves valid postgres DDL + round-trip for + the broad set (SMALLINT/INT/BIGINT/REAL/DOUBLE_PRECISION/CHAR/TEXT/NUMERIC/DATE/TIME/TIMESTAMP/BYTEA). +- **DECIMAL dead reference** — removed `DECIMAL` from the doc note; there is no `prop.DECIMAL()` + factory to construct it. +- **empty-array upsert untested** — `probe` now upserts an explicit `[]` and reads back `[]`. +- **nullable + updatable native array CVP transition untested** — new `flux` entity proves the + value→null→value version bumps and the null→null / value→same no-ops. +- **NUMERIC(precision, scale) array untested** — `probe`'s `prices numeric(10, 2)[]` shows the + type modifier composes with the array suffix, in DDL and via unit test. +- **`castPropertyToWhereClauseConditional` had no native-array unit case** — added unit cases that + show a native array compares by value, never via the join-table sha256 hash. + +the l3 review then surfaced two genuine consensus blockers (both raised independently by r10 and +r11), each fixed at the root: +- **NULL-element version bump (correctness blocker)** — the native-array where-clause used plain + `=`, but postgres array `=` returns NULL (not TRUE) when an array holds a NULL element + (`{a,NULL} = {a,NULL}` is NULL). so an array with a NULL element would insert a spurious new + version on every re-upsert, a violation of the vision's "no bump on unchanged" acceptance. the + fix compares native arrays with `IS NOT DISTINCT FROM` (null-safe on the whole array AND its + elements — the vision's own "option A"). proven by a `flux` NULL-element re-upsert test (no bump). +- **`define/` → `generate/` layer inversion (arch blocker)** — the i002 fix had `defineProperty.ts` + import `isNativeArrayProperty` from `generate/utils/`, which inverts the universal `generate/ → + define/` layer convention (a latent circular-import landmine). the fix moved `isNativeArrayProperty` + + `isJoinTableArrayProperty` to a neutral `domain.operations/utils/` that both `define/` and + `generate/` import downward. + +further l3 nitpicks closed: snapshot the generated DDL/upsert/view SQL for eyeball review; a +dedicated `castPropertyToTableColumnValueReference` unit test (the 3-way native/hash/scalar +branch); an empty enum array `[]` through the real `<@` CHECK; a native primitive + enum array in +the acceptance fixture (contract-grain coverage per `rule.require.test-coverage-by-grain`); a +tightened `castArrayPropertiesToValuesHashProperties` guard (asserts `isJoinTableArrayProperty`, +not just `.array`); and a fail-fast guard against a nested `ARRAY_OF(ARRAY_OF(...))`. + +one l3 nitpick was converged by articulation rather than a change: r11's "carry enum values as +data instead of a regex re-derive from the generated `IN (...)` string". the current +`castCheckToArrayElementMembership` works today and already fails fast if the shape does not +match, so it is safe; a private enum-values field on `Property` is a deeper `define/` contract +change beyond this wish's additive scope. flagged as a clean future refactor. + +the i007 l3 round then surfaced one last genuine blocker (r10): +- **readme not updated (ergo-friction blocker)** — the package readme's only array example was + still `ARRAY_OF(REFERENCES(...))`, so a consumer who reads the primary onboard doc had no way + to discover native primitive/enum arrays. fixed: the `home` fixture in `readme.md` now shows a + native `tags: ARRAY_OF(VARCHAR())` and `statuses: ARRAY_OF(ENUM([...]))` beside the extant + reference-array example, each commented with its storage (native column vs join table); and the + feature-list line that claimed "mapping tables (if array properties exist)" was corrected to + scope mapping tables to reference/uuid arrays only, with primitive/enum arrays noted as native + columns. + +the i008/i009 l3 rounds (r10, insatiable) then surfaced coverage nitpicks and one final genuine +blocker, all closed: +- **time/timestamp/bytea arrays not asserted on read (i008 nitpick)** — the `probe` round-trip + upserted them but asserted only 10 of 13 element types. fixed: `blobs` now carries real values + and the test asserts `time[]`/`timestamp[]`/`bytea[]` on read-back. +- **enum array with a NULL element untested vs `<@` (i008 nitpick)** — added a test and RAN it + against real postgres: a NULL element does NOT satisfy `<@`, so the CHECK rejects it (fail-loud, + the pit-of-success). the test asserts the rejection. +- **order-dependent check guard, silently bypassable (i009 BLOCKER)** — `ARRAY_OF`'s check guard + only saw a check baked into the element prop; the idiomatic `{ ...ARRAY_OF(x), check }` order + attaches the check AFTER `ARRAY_OF` returned, so a scalar check spread on that way bypassed the + guard and `generateTable` emitted operator-invalid array DDL that failed only at apply time. + fixed at the root: `castCheckToArrayElementMembership` moved to the neutral + `domain.operations/utils/` and made idempotent (passthrough on an already-`<@` check, recast on + `IN (...)`, throw on scalar), then applied at the DDL emission site in `generateTable.ts` gated + by `isNativeArrayProperty`. the guard now fires regardless of construction order; `ARRAY_OF` + keeps its call-time recast for early feedback (idempotent, so no double transform). two new + `generateTable` unit tests prove both the valid enum-spread recast and the scalar-spread throw. +- **changed-direction proven for only varchar (i009 nitpick)** — the version-bump-on-change test + now changes each updatable array element type in turn (varchar/numeric/boolean/timestamptz/enum) + and asserts exactly one bump per change. + +the i010 round (r10, 0 blockers — the check-guard blocker confirmed fixed) raised four nitpicks; +the two feature-specific ones closed, the two repo-wide ones held: +- **no acceptance-level content snapshot (i010 nitpick, closed)** — the acceptance test asserted + only that the CLI does not throw. it now reads the CLI-produced artifacts and asserts + + snapshots the native-array content (`tags varchar[]`, `amenities varchar[]` + `<@` check, and + the view's straight-through select with null→`[]` coalesce). this aligns with + rule.require.test-coverage-by-grain (contracts → acceptance test + snapshots). +- **no dedicated test file for `castCheckToArrayElementMembership` (i010 nitpick, closed)** — it + was covered only indirectly. added `castCheckToArrayElementMembership.test.ts` with all three + branches (idempotent passthrough, enum recast, throw-on-custom-check). +- **`sql-schema-control apply` proven by proxy + the 3-site filter / string-coupled recast tech + debt (i010 nitpicks, held)** — these are repo-wide conventions and previously-accepted tradeoffs + (integration runs generated DDL via `createTablesForEntity`, as every feature in this repo does; + the single classifier backs the 3-site negation; the recast fails safe and is now idempotent + + tested at two sites). held as documented, consistent with i007/i008. + +## self-review refinements (8 self-reviews, all promised) + +the 8 self-reviews surfaced and fixed real issues on top of the base execution: + +- **coverage (behavior-declaration)** — the view's native-array selector omitted the vision's + `[answered]` null→`[]` coalesce; added `coalesce(col, array[]::type)` in + `castPropertyToSelector.ts` so the DAO sees one shape. new `castPropertyToSelector.test.ts` + (6 cases) proves it. +- **conventions** — `isNativeArrayProperty` took a positional arg; recast to the codebase's + destructured `({ property })` across all 12 call sites. +- **role-standards (adherence)** — a stale file header in `castPropertyToTableColumnValueReference.ts` + (claimed all arrays are hashed) rewritten to describe the native/join-table/scalar branches; the + enum-rejection integration test's `let` + `as`-cast + `!` error-capture rewritten to the repo's + clean `try/catch` idiom; two emphasis-caps "NATIVE" jsdocs lowercased. +- **role-standards (coverage)** — the core discriminator `isNativeArrayProperty` had no dedicated + unit test; added `isNativeArrayProperty.test.ts` (9 cases) that also locks the ref/uuid-FIRST + order invariant. + +## acceptance criteria (from the wish) — met + +a `sensor` entity with `tags`/`readings`/`statuses` native arrays: +1. accepted without a throw ✓ +2. applies to real postgres (createTablesForEntity) ✓ +3. the hydrated view presents each as its element array (`varchar[]`/`numeric[]`/`boolean[]`/ + `timestamp with time zone[]`/`[]`) ✓ +4. round-trip: upsert reads back equal; re-upsert same → no version bump; changed → one new + version ✓ + +## a note for the reviewer + +- the storage split is derived from element kind (no new `Property` field / schema change). + the `[wisher]` fork (native vs join-table vs per-prop knob) landed on native for + primitive/enum, per the wish's stated default; a per-prop knob was not built (out of scope). +- uuid arrays intentionally stay join-table (the wish scopes them "unchanged"); native `uuid[]` + is captured as a follow-up dream (gh issue #89), since the element-FK objection does not + apply to bare uuids. diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.guard b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.guard new file mode 100644 index 0000000..8ea8cfd --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.guard @@ -0,0 +1,292 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/5.3.verification.guard + +artifacts: + # track verification progress + - "$route/5.3.verification.yield.md" + # track actual implementation in src/ + - "src/**/*" + +reviews: + self: + - slug: has-behavior-coverage + say: | + double-check: does the verification checklist show every behavior from wish/vision has a test? + + - is every behavior in 0.wish.md covered? + - is every behavior in 1.vision.md covered? + - can you point to each test file in the checklist? + + **if any behavior lacks a test, write the test NOW.** do not pass this gate with gaps. + + - slug: has-zero-test-skips + say: | + double-check: did you verify zero skips — and REMOVE any you found? + + - no .skip() or .only() found? + - no silent credential bypasses? + - no prior failures carried forward? + + **if you found skips, did you remove them and make those tests pass?** + + this is buttonup. skips are gaps. gaps get fixed, not noted. + + - slug: has-all-tests-passed + say: | + double-check: did all tests pass? prove it with a VERBATIM terminal block. + + **zero unproven claims. a claim is not proof — only pasted output is.** + + for EACH test suite you must paste, verbatim from your terminal: + - the exact command you ran + - the final summary line (counts) + - the exit code + + required proof shape — paste your real run, not this template: + ``` + $ npm run test:unit + > 47 passed, 0 failed, 0 skipped + > exit 0 + ``` + + the articulation is REJECTED if it: + - says "all tests pass" without a pasted block + - paraphrases the result instead of a verbatim paste + - pastes a partial run (one suite) and claims the rest + - shows any non-zero exit, any failure, or any skip + + if you did not paste the command AND its output AND the exit code, you did + not prove it — and an unproven pass is a feigned review. + + zero tolerance for extant failures: + - "it was already broken" is not an excuse — fix it + - "it's unrelated to my changes" is not an excuse — fix it + - flaky tests must be stabilized, not tolerated + - every failure is your responsibility now + + zero tolerance for flakes — when a test flakes, deflake it: + - a test that passes on re-run is not "fine" — it is a live defect + - do NOT retry until green and move on; that hides the flake for the next run + - run the structured deflake workflow: rhx cicd.deflake init + - diagnose the root cause (race, shared state, clock, unmasked volatile output), + repair it, and prove the fix holds across re-runs + - a masked volatile output (see the snapshot rules) is the usual fix for an + output-driven flake + + zero tolerance for fake tests: + - tests that always pass are fraud + - tests that mock the system under test prove no behavior + - tests must verify real behavior + + zero tolerance for credential excuses: + - "i don't have creds" means get them or mock them + - silent bypasses are forbidden + - if creds block tests, that is a BLOCKER — not a deferral + + - slug: has-preserved-test-intentions + say: | + double-check: did you preserve test intentions? + + for every test you touched: + - what did this test verify before? + - does it still verify the same behavior after? + - did you change what the test asserts, or fix why it failed? + + forbidden: + - weaken assertions to make tests pass + - remove test cases that "no longer apply" + - change expected values to match broken output + - delete tests that fail instead of fix code + + the test knew a truth. if it failed, either: + - the code is wrong — fix the code + - the test has a bug — fix the bug, keep the intention + - requirements changed — document why, get approval + + to "fix tests" via changed intent is not a fix — it is at worst + malicious deception, at best reckless negligence. unacceptable. + + - slug: has-snap-changes-rationalized + say: | + double-check: is every `.snap` file change intentional and justified? + + for each `.snap` file in git diff: + 1. what changed? (added, modified, deleted) + 2. was this change intended or accidental? + 3. if intended: what is the rationale? + 4. if accidental: revert it or explain why the new output is an improvement + + common regressions caught here: + - output format degraded (lost alignment, lost structure) + - error messages became less helpful + - timestamps or ids leaked into snapshots (flaky) + - extra output added unintentionally + + forbidden: + - "updated snapshots" without per-file rationale + - bulk snapshot updates without review + - regressions accepted without justification + + every snap change tells a story. make sure the story is intentional. + + - slug: has-critical-paths-frictionless + say: | + double-check: are the critical paths frictionless in practice? prove it with a + pasted runthrough, not a claim of "smooth". + + "frictionless" is not a vibe — it is a concrete rubric. read the ergonomist + brief `def.frictionless` (in your booted ergonomist briefs) and score against it. + + find the critical paths. if a repros artifact exists, take them from it: + - .behavior/v2026_07_24.native-primitive-enum-array-columns/3.2.distill.repros.experience.*.md + if no repros artifact exists, take the critical paths straight from the + wish + vision (the primary usecases a human runs) — absence of repros is + NOT an excuse to skip this review. + + for EACH critical path, paste verbatim from your terminal: + - the exact command(s) you ran to walk the path + - the real output they produced + + then score that pasted output against every criterion in def.frictionless, + one line each. + + the articulation is REJECTED if it: + - claims "smooth" or "frictionless" without a pasted runthrough + - paraphrases the outcome instead of a verbatim paste + - walks only one path and claims the rest + - skips the review because repros is absent + + a claim of frictionless is not proof — a pasted session scored against + def.frictionless is. if the paste shows friction, fix it NOW; do not note it. + + - slug: has-ergonomics-validated + say: | + double-check: is the actual input/output ergonomic? prove it with a pasted + capture of the real i/o, not a claim of "matches". + + "ergonomics" is not a vibe — it is a concrete rubric. read the ergonomist + brief `def.ergonomic` (in your booted ergonomist briefs) and score against it. + + for EACH critical path, capture the real i/o verbatim from the built + artifact's run and paste it. + + then choose ONE frame: + - if a repros artifact exists (.behavior/v2026_07_24.native-primitive-enum-array-columns/3.2.distill.repros.experience.*.md), + paste a two-column comparison — planned i/o (from repros) vs actual i/o + (captured) — and flag any drift between them. + - if no repros artifact exists, score the captured i/o directly against every + criterion in def.ergonomic, one line each. absence of repros is NOT an + excuse to skip this review. + + the articulation is REJECTED if it: + - claims "matches" or "ergonomic" without the pasted capture + - fills the actual/captured column with a paraphrase instead of real output + - skips the review because repros is absent + + if the ergonomics fall short, either: + - update repros to reflect the better design (when repros exists), or + - fix the implementation to meet the def.ergonomic rubric + + drift you cannot see in a paste is drift you did not check. + + - slug: has-fixed-all-gaps + say: | + final buttonup check: did you FIX every gap you found, or just detect it? + + **this is the buttonup phase. detection is not enough — you must fix.** + + look back at all the reviews above. for every gap you identified: + - absent test coverage → did you WRITE the test? + - absent prod coverage → did you IMPLEMENT the behavior? + - failed test → did you FIX the code or test? + - skipped test → did you REMOVE the skip and make it pass? + + **zero omissions.** if any review above surfaced a gap, that gap must be fixed before you pass this gate. + + ask yourself: + - did i just note the gap, or did i actually fix it? + - is there any item marked "todo" or "later"? (forbidden) + - is there any coverage marked incomplete? (forbidden) + + **if you detected it, you fixed it.** prove it — a bare "all fixed" is a feigned review. + + this review is the closer. the articulation must ENUMERATE, not summarize. + for EACH gap any review above surfaced, write one line: + + - the gap (which review found it, what it was) + - the fix (the exact file + what changed, or the commit/diff reference) + + if a review above found no gap, say so per review — do not skip it silently. + + the articulation is REJECTED if it: + - says "all gaps fixed" without the per-gap enumeration + - references a fix with no file/diff pointer + - leaves any surfaced gap unaddressed + + this is the final self-review. you are about to hand off to peer review. + prove every item above was addressed — with a pointer — not deferred. + + peer: + # --- level 1: cheap reviewers (run first, in parallel) --- + + - slug: repo-rules + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=.this/**/rule.*.md' --optional rules --diffs since-main --paths-with '**/*.{ts,sh}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-contract-snapshots + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.contract-snapshot-exhaustiveness.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh}' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-external-contracts + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.external-contract-integration-tests.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.unit/rule.forbid.remote-boundaries.md' --diffs since-main --paths-with '**/*.{ts,sh}' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-acceptance-journey-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/ergonomist/rule.require.acceptance-journey-coverage.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh}' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-snapshot-visual-blemishes + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/ergonomist/rule.forbid.snapshot-visual-blemishes.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/lang.tones/*.md.min' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.contract-snapshot-exhaustiveness.md' --diffs since-main --paths-with '**/*.{ts,sh}' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-given-when-then + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/rule.require.given-when-then.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/criteria.given_when_then.[seed].v3.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/lessons.howto/*.md.min' --diffs since-main --paths-with '**/*.test.ts' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-test-intent + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.forbid.test-intent-violations.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/work.flow/refactor/rule.require.review-test-changes.md.min' --diffs since-main --paths-with '**/*.test.ts' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-test-scope-purity + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.*/rule.*.md' --diffs since-main --paths-with '{src,blackbox}/**/*.test.ts' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- level 3: expensive reviewers (run after level 1 is terminal) --- + + - slug: enroll-verif-snapshot-coverage + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,ergonomist,mechanic -p 'review the diff for snapshot coverage on contract endpoints and acceptance test user journey coverage. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + + - slug: enroll-verif-snapshot-blemishes + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,ergonomist,mechanic -p 'review the diff for experiential and visual blemishes in snapshotted acceptance test journeys. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + + - slug: enroll-verif-test-intent + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for test intent violation diffs. if any of the diffs related to tests loosened assertions or changed the criteria, this is a blocker. tests were added for a reason. we have to maintain the behavior they locked in. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + +judges: + # enforce peer reviews pass with zero blockers + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 0 diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stamp b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stamp new file mode 100644 index 0000000..d214b8e --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stamp @@ -0,0 +1,85 @@ +🦉 the way speaks for itself + +🗿 route.stone.set + ├─ stone = 5.3.verification + ├─ passage = allowed + ├─ guard + │ ├─ artifacts + │ │ ├─ $route/5.3.verification.yield.md + │ │ └─ src/**/* + │ ├─ reviews + │ │ ├─ r1: repo-rules (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r001._.given.by_peer.repo-rules.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r001._.taken.by_self.repo-rules.md + │ │ ├─ r2: ergo-contract-snapshots (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r002._.given.by_peer.ergo-contract-snapshots.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r002._.taken.by_self.ergo-contract-snapshots.md + │ │ ├─ r3: mech-external-contracts (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r003._.given.by_peer.mech-external-contracts.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r003._.taken.by_self.mech-external-contracts.md + │ │ ├─ r4: ergo-acceptance-journey-coverage (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r004._.given.by_peer.ergo-acceptance-journey-coverage.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r004._.taken.by_self.ergo-acceptance-journey-coverage.md + │ │ ├─ r5: ergo-snapshot-visual-blemishes (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r005._.given.by_peer.ergo-snapshot-visual-blemishes.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r005._.taken.by_self.ergo-snapshot-visual-blemishes.md + │ │ ├─ r6: mech-given-when-then (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r006._.given.by_peer.mech-given-when-then.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r006._.taken.by_self.mech-given-when-then.md + │ │ ├─ r7: mech-test-intent (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r007._.given.by_peer.mech-test-intent.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r007._.taken.by_self.mech-test-intent.md + │ │ ├─ r8: mech-test-scope-purity (l1, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r008._.given.by_peer.mech-test-scope-purity.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r008._.taken.by_self.mech-test-scope-purity.md + │ │ ├─ r9: enroll-verif-snapshot-coverage (l3, 3/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i003.aa29bed8f82c4619f9.r009._.given.by_peer.enroll-verif-snapshot-coverage.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i003.aa29bed8f82c4619f9.r009._.taken.by_self.enroll-verif-snapshot-coverage.md + │ │ ├─ r10: enroll-verif-snapshot-blemishes (l3, 2/3) + │ │ │ ├─ approved, cached + │ │ │ ├─ 0 blockers ✓ + │ │ │ ├─ 0 nitpicks ✓ + │ │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r010._.given.by_peer.enroll-verif-snapshot-blemishes.md + │ │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r010._.taken.by_self.enroll-verif-snapshot-blemishes.md + │ │ └─ r11: enroll-verif-test-intent (l3, 2/3) + │ │ ├─ approved, cached + │ │ ├─ 0 blockers ✓ + │ │ ├─ 0 nitpicks ✓ + │ │ ├─ tallied by reviewer@fireworks/deepseek/v4-flash + │ │ ├─ given: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r011._.given.by_peer.enroll-verif-test-intent.md + │ │ └─ taken: .behavior/v2026_07_24.native-primitive-enum-array-columns/.reviews/peer/5.3.verification._.review.i005.868550f64fca822b7e.r011._.taken.by_self.enroll-verif-test-intent.md + │ └─ judges + │ └─ j1: $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 0 + │ └─ · cached + │ ├─ on $route/5.3.verification.yield.md + │ └─ on src/**/* + │ + └─ the way continues, run + └─ rhx route.drive \ No newline at end of file diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stone b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stone new file mode 100644 index 0000000..f2757dd --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.stone @@ -0,0 +1,304 @@ +prove the deliverable works via test verification — fix all gaps + +--- + +## .what + +this is the verification gate — the **buttonup phase**. + +you cannot pass execution without proof that all tests pass. more importantly: **test coverage enforces prod coverage**. if tests are absent, prod is incomplete. if prod is incomplete, fix it. + +## .the buttonup mandate: zero omissions + +**this is not a detection phase. this is a completion phase.** + +when you find a gap, you do not note it and move on. you **fix it**. + +| gap type | action | +|----------|--------| +| absent test coverage | write the test NOW | +| absent prod coverage | implement the behavior NOW | +| failed test | fix the code or fix the test NOW | +| skipped test | remove the skip and make it pass NOW | + +**if you detect it, you fix it. no exceptions.** + +## .strictness: zero tolerance, zero exceptions + +**this gate enforces absolute standards. there is no leniency.** + +| constraint | definition | +|------------|------------| +| zero deferrals | you cannot defer any test to later. all tests pass now or you fail. | +| zero fake tests | tests must verify real behavior. assertions that always pass are fraud. | +| zero unproven claims | every claim of "test passes" must cite the exact command run and output. | +| zero credential excuses | "i don't have creds" is not an excuse. get them, mock them, or fail. | +| zero skips | .skip() and .only() are forbidden. silent bypasses are forbidden. | +| zero exceptions | there are no special cases. the rules apply to all. | +| zero omissions | if you find a gap in coverage, you fix it — test or prod. | + +**if a blocker prevents tests from run, that is a BLOCKER. full stop.** + +you do not proceed. you do not defer. you fix it or you fail the gate. + +## .why + +**why does this gate exist?** + +your crew is about to review a pr you wrote. they need proof it works — not words, proof. tests are that proof. + +**test coverage drives prod coverage.** if a behavior lacks a test, that behavior is unproven. unproven behaviors are incomplete deliverables. the test proves the implementation exists and works. + +without this gate: +- tests might fail and nobody notices +- tests might be skipped and nobody notices +- behaviors might lack coverage and nobody notices +- broken code ships to peers +- incomplete implementations slip through + +with this gate: +- every test passes or you fix it +- every behavior has coverage or you add it +- every skip is removed or justified +- proven code ships to peers +- **gaps get fixed, not deferred** + +**the cardinal rules**: +1. never leave behavior without true, dependable test coverage +2. never offload work onto your crew unless there is truly, fundamentally no other option +3. never claim a test passes without cite of the exact command and output + +you fix it yourself. you exhaust every option: debug, research, try alternatives. only when you hit a wall that is physically impossible to climb alone — credentials only the foreman possesses, access only they can grant — only then may you ask for help. + +## .how + +reference the below for full context +- .behavior/v2026_07_24.native-primitive-enum-array-columns/0.wish.md +- .behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.md +- .behavior/v2026_07_24.native-primitive-enum-array-columns/2.1.criteria.blackbox.md (if declared) +- .behavior/v2026_07_24.native-primitive-enum-array-columns/3.2.distill.repros.experience.*.md (if declared) ← **repros artifact** + +--- + +### step 1: emit verification checklist + +emit to +- .behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.yield.md + +this is your roadmap. emit it first, then work through it step by step. + +**checklist structure:** + +``` +## verification checklist + +### behavior coverage (with reference to repros) + +for each journey sketched in repros, verify it was implemented with snapshots. + +| journey (from repros) | test file | snapshots? | critical path? | ergonomics ok? | status | +|-----------------------|-----------|------------|----------------|----------------|--------| +| {journey 1} | {path} | ✓ / ✗ | ✓ frictionless / needs work | ✓ natural / needs work | ⏳ | +| {journey 2} | {path} | ✓ / ✗ | ✓ frictionless / needs work | ✓ natural / needs work | ⏳ | +... + +### zero skips verified +- [ ] no .skip() or .only() found +- [ ] no silent credential bypasses +- [ ] no prior failures carried forward + +### snapshot coverage for contract outputs + +each public contract needs dedicated snapshots that demonstrate its stdout for: +- **vibechecks in prs** — reviewers see actual output without executing code +- **drift detection** — changes to output surface in diffs over time + +| contract | output variants | snapshot file | status | +|----------|-----------------|---------------|--------| +| {command 1} | success, error, help | {path.snap} | ⏳ | +| {command 2} | success, error, help | {path.snap} | ⏳ | +... + +checklist: +- [ ] every new cli command has `.snap` snapshots for stdout/stderr +- [ ] every new app screen has `.snap` snapshots for screenshots +- [ ] every new sdk method has `.snap` snapshots for responses +- [ ] each output variant is exercised (success, error, edge cases) +- [ ] snapshots demonstrate actual output, not just "it ran" + +### snapshot change rationalization + +for each `.snap` file changed, rationalize whether the change was intended or accidental: + +| snap file | change type | intended? | rationale | +|-----------|-------------|-----------|-----------| +| {path.snap} | added / modified / deleted | yes / no | {why this change is correct} | +... + +checklist: +- [ ] every `.snap` change has been reviewed +- [ ] intended changes have clear rationale +- [ ] accidental changes have been reverted or justified as improvements + +### tests executed — with proof + +**every test run must be proven with exact command and output.** + +| test suite | command run | result | proof (exit code + summary) | +|------------|-------------|--------|----------------------------| +| types | `npm run test:types` | ✓ / ✗ | exit 0, no errors | +| lint | `npm run test:lint` | ✓ / ✗ | exit 0, no errors | +| format | `npm run test:format` | ✓ / ✗ | exit 0, no errors | +| unit | `npm run test:unit` | ✓ / ✗ | exit 0, N tests passed | +| integration | `npm run test:integration` | ✓ / ✗ | exit 0, N tests passed | +| acceptance | `npm run test:acceptance` | ✓ / ✗ | exit 0, N tests passed | + +checklist: +- [ ] every test command was run (not "i think it passed") +- [ ] every test command output was observed (not assumed) +- [ ] the exact exit code was verified (0 = pass, non-zero = fail) +- [ ] no tests were skipped, mocked, or faked + +**zero unproven claims.** if you claim a test passes, cite the command and output. + +### contract output snapshot exhaustiveness + +**every user-faced contract must have exhaustive snapshot coverage.** + +| contract type | contract | positive path snapped? | negative path snapped? | edge cases snapped? | +|---------------|----------|------------------------|------------------------|---------------------| +| cli | {command} | ✓ / ✗ | ✓ / ✗ | ✓ / ✗ | +| api | {endpoint} | ✓ / ✗ | ✓ / ✗ | ✓ / ✗ | +| sdk | {method} | ✓ / ✗ | ✓ / ✗ | ✓ / ✗ | + +checklist: +- [ ] every cli command has stdout/stderr snapshots for success, error, and help +- [ ] every api endpoint has response snapshots for success and error codes +- [ ] every sdk method has return value snapshots for success and error +- [ ] every contract has edge case snapshots (empty input, invalid input, boundary) + +**zero gaps in caller experience.** the reviewer must see exactly what callers see. + +### blockers +- none (or list handoff references) +``` + +update the checklist as you complete each step below. + +--- + +### step 2: verify AND FIX behavior coverage + +walk through wish and vision: +- every behavior promised must have an acceptance test +- for each behavior, you can point to the test file +- no behavior left untested + +**why?** your crew trusts the test suite. if a behavior isn't tested, it isn't proven. untested behaviors are unverified promises. + +**test coverage enforces prod coverage.** if a test is absent, either: +1. the behavior was not implemented → IMPLEMENT IT NOW +2. the behavior was implemented without a test → WRITE THE TEST NOW + +if a behavior lacks a test, **write one NOW**. do not move on with gaps. update your checklist when done. + +--- + +### step 3: verify AND FIX zero skips + +scan for forbidden patterns: +- `.skip()` or `.only()` in test files +- `if (!credentials) return` or similar silent bypasses +- prior failures carried forward (known-broken tests) + +**why?** failures are better than skips. skips hide problems. failures expose them. a skipped test is a lie — it pretends coverage exists when it doesn't. + +**this is buttonup.** if you find skips: +1. REMOVE the skip +2. MAKE the test pass (fix the code or fix the test) +3. update your checklist + +do not note skips and move on. fix them. all tests must run. + +--- + +### step 4: run all tests AND FIX all failures + +run each test suite and **cite the exact command and output**. + +```bash +npm run test:types # cite exit code +npm run test:lint # cite exit code +npm run test:format # cite exit code +npm run test:unit # cite exit code + test count +npm run test:integration # cite exit code + test count +npm run test:acceptance # cite exit code + test count +``` + +all must pass — no exceptions. no deferrals. no "i'll fix it later." + +**this is buttonup.** if tests fail, fix them. that is the job. + +failures indicate one of: +1. prod code is broken → FIX THE PROD CODE +2. test has a bug → FIX THE TEST BUG (preserve intention) +3. coverage gap exists → FILL THE GAP + +**consider all failures as defects from this pr.** there are no "prior failures." + +if a test was broken before you started — fix it. if a test is flaky — fix it. if a test fails for reasons unrelated to your changes — fix it anyway. you do not get to say "that was already broken." you are here now. you fix it. + +**take initiative. take ownership.** + +**preserve test intentions.** when you fix a test, you fix why it failed — not what it tests. to change what a test verifies is not a fix. it is at worst malicious deception, at best reckless negligence. the test knew a truth. if it fails, either the code is wrong or the test has a bug. fix the cause, not the assertion. + +**zero fake tests.** a test that always passes is fraud. a test that skips is a lie. a test that mocks the system under test proves nothingness. tests must verify real behavior against real code. + +**escalation path:** +1. debug the failure — read the error, understand the cause +2. research — search for similar issues, read docs +3. try alternatives — different approach, different tool +4. ask for help — other resources, other clones +5. deeper research — exhaust every option +6. only if insurmountable — emit handoff (see step 5) + +**ask yourself at each level:** +- did i read the error message carefully? +- did i search for similar issues? +- did i try a different approach? +- did i isolate the problem? +- did i ask for help? +- did i exhaust every option? + +you move to handoff only when you can answer "yes" to all of the above and still cannot proceed. + +update your checklist when all tests pass. + +--- + +### step 5: handoff (only if insurmountable) + +a handoff is a document that transfers work to your foreman because you hit a wall that is physically impossible to climb alone. + +**foreman-only blockers:** +- credentials only the foreman possesses +- external access only the foreman can grant +- approval that requires foreman authority + +handoff is the absolute last resort. you must exhaust every option before you consider it. + +if you need to emit a handoff: + +emit to +- .behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.handoff.v$N.to_foreman.md + +**handoff must include:** +1. what you tried (list every approach you attempted) +2. why each approach failed (be specific) +3. what makes this fundamentally impossible without foreman intervention +4. is this truly a "foreman possesses the key" situation? +5. rewind instruction: `rhx route.stone.set --stone 5.3.verification --as rewound` + +your crew should read your handoff and think: "yes, there was truly no other way." + +update your checklist to reference the handoff. diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.yield.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.yield.md new file mode 100644 index 0000000..16fcc5d --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/5.3.verification.yield.md @@ -0,0 +1,108 @@ +# 5.3 verification — native primitive + enum array columns + +## verification checklist + +### behavior coverage (each wish/vision promise → a test) + +| behavior (from wish/vision) | test file | snapshots? | critical path? | ergonomics ok? | status | +|-----------------------------|-----------|------------|----------------|----------------|--------| +| `ARRAY_OF()` accepted, no throw | `define/defineProperty.test.ts` (data-driven, 15 types) | n/a | ✓ frictionless | ✓ natural | ✓ | +| `ARRAY_OF(ENUM([...]))` accepted, recast to `<@` | `define/defineProperty.test.ts` | n/a | ✓ | ✓ | ✓ | +| native array column emitted on base/version (not join table) | `generate/nativeArrayColumns.integration.test.ts` (sensor DDL) | ✓ DDL snapshot | ✓ | ✓ | ✓ | +| reference/uuid arrays keep join-table path (no regression) | `generate/nativeArrayColumns.integration.test.ts` + extant array tests | ✓ | ✓ | ✓ | ✓ | +| upsert writes the array in one arg (no per-element loop) | `nativeArrayColumns.integration.test.ts` (upsert snapshot + round-trip) | ✓ upsert snapshot | ✓ | ✓ | ✓ | +| view selects native array straight through + null→`[]` coalesce | `nativeArrayColumns.integration.test.ts` (beacon, view snapshot) | ✓ view snapshot | ✓ | ✓ | ✓ | +| CVP: unchanged array → no bump; changed → one bump (per type) | `nativeArrayColumns.integration.test.ts` (idempotent + per-type change) | n/a | ✓ | ✓ | ✓ | +| NULL-element array → no spurious bump (`IS NOT DISTINCT FROM`) | `nativeArrayColumns.integration.test.ts` (flux NULL-element) | n/a | ✓ | ✓ | ✓ | +| enum array element outside set → rejected (`<@` CHECK) | `nativeArrayColumns.integration.test.ts` (rejection + snapshot) | ✓ error snapshot | ✓ | ✓ | ✓ | +| enum array NULL element → rejected by CHECK | `nativeArrayColumns.integration.test.ts` | n/a | ✓ | ✓ | ✓ | +| empty array `{}` / empty enum array → accepted, reads `[]` | `nativeArrayColumns.integration.test.ts` (probe empty, empty-enum) | n/a | ✓ | ✓ | ✓ | +| broad primitive types (13) valid DDL + round-trip | `nativeArrayColumns.integration.test.ts` (probe) | n/a | ✓ | ✓ | ✓ | +| mixed native + join-table entity, no collision | `nativeArrayColumns.integration.test.ts` (gadget) | n/a | ✓ | ✓ | ✓ | +| serial pseudo-type element → rejected at declare | `define/defineProperty.test.ts` (+ snapshot) | ✓ | ✓ | ✓ | ✓ | +| nested `ARRAY_OF(ARRAY_OF(...))` → rejected at declare | `define/defineProperty.test.ts` (+ snapshot) | ✓ | ✓ | ✓ | ✓ | +| scalar/custom check on native array → rejected (any call order) | `generate/entityTables/generateTable/generateTable.test.ts` + `utils/castCheckToArrayElementMembership.test.ts` | ✓ | ✓ | ✓ | ✓ | +| CLI `generate` produces native-array DDL/view end-to-end | `contract/commands/generate.acceptance.test.ts` (+ content snapshots) | ✓ | ✓ | ✓ | ✓ | +| CLI `generate` produces native-array upsert fn (write path) | `contract/commands/generate.acceptance.test.ts` (`upsert_home.sql` snapshot) | ✓ upsert snapshot | ✓ | ✓ | ✓ | +| roundtrip instance data (in→view out) snapshotted per boundary | `nativeArrayColumns.integration.test.ts` (`asStableRow` data snapshots) | ✓ data snapshots | ✓ | ✓ | ✓ | +| CLI output **applied to real postgres** → full upsert+view roundtrip | `contract/commands/generate.acceptance.test.ts` (parcel: literal generated `.sql` applied to testdb, `upsert_parcel` + `view_parcel_current` executed, read-back asserted + snapshotted; re-upsert no-op; enum change → one version) | ✓ roundtrip + version snapshots | ✓ | ✓ | ✓ | + +> note: the acceptance roundtrip uses a self-contained native-array entity (`parcel`) rather than +> `home` itself, because `home`'s dependency graph transitively emits a `user` table — a postgres +> reserved word the generator writes unquoted — which blocks a literal apply of home's own graph +> (a prior generator limitation, out of scope for this wish). `parcel` exercises the identical +> native-array code paths (static primitive array, static numeric array, updatable enum array + +> `<@` check, CVP change-detection) end-to-end against a real postgres. + +### zero skips verified +- [x] no `.skip()` or `.only()` found (grep over `src/`, no matches) +- [x] no silent credential bypasses (integration/acceptance fail-fast via keyrack unlock) +- [x] no prior failures carried forward + +### snapshot coverage for contract outputs + +| contract | output variants | snapshot file | status | +|----------|-----------------|---------------|--------| +| CLI `generate` (native primitive array) | `home.sql` — `tags varchar[]` | `contract/commands/__snapshots__/generate.acceptance.test.ts.snap` | ✓ | +| CLI `generate` (native enum array + check) | `home_version.sql` — `amenities varchar[]` + `<@` check | same | ✓ | +| CLI `generate` (view straight-through) | `view_home_current.sql` — coalesce selects | same | ✓ | +| CLI `generate` (native-array write path) | `upsert_home.sql` — `in_tags`/`in_amenities varchar[]` + `IS NOT DISTINCT FROM` | same | ✓ | +| CLI `generate` (help + invalid declaration) | `--help` text + `UserInputError` | same | ✓ | +| generator DDL/upsert/view (native arrays) | sensor DDL, upsert fn, view | `generate/__snapshots__/nativeArrayColumns.integration.test.ts.snap` | ✓ | +| roundtrip instance data (per boundary) | view/version row data — all types, empty, NULL cell, value↔null transitions, NULL element, mixed native+join | same | ✓ | +| declare-time + runtime errors (serial, nested, custom-check, enum out-of-set, enum NULL element) | error text | `define/` + `generate/` + `utils/` snapshots | ✓ | + +- [x] the new CLI output paths carry `.snap` snapshots (success path + generated content) +- [x] each error variant is exercised and snapshotted +- [x] snapshots demonstrate actual output, not just "it ran" + +### snapshot change rationalization + +| snap file | change type | intended? | rationale | +|-----------|-------------|-----------|-----------| +| `generate.acceptance.test.ts.snap` | added | yes | acceptance content for the native-array DDL/view + `--help` + invalid-declaration error + the upsert-function write path (`upsert_home.sql`); plus 2 parcel full-roundtrip snapshots (view read-back instance + before/after version rows) captured from the literal CLI output applied to a real postgres | +| `nativeArrayColumns.integration.test.ts.snap` | added over the feature life | yes | DDL/upsert/view + error snapshots, plus roundtrip instance-data snapshots (`asStableRow`, volatile id/uuid/timestamp/fk stripped) for every boundary: all types, empty arrays, NULL cell→[], value↔null CVP transitions, NULL element unchanged, mixed native+join, broad primitives | +| `defineProperty.test.ts.snap` | modified | yes | shared-message update for `castCheckToArrayElementMembership` (custom-check throw) | +| `castCheckToArrayElementMembership.test.ts.snap` | added | yes | dedicated util test (nitpick #4 close) | + +- [x] every `.snap` change reviewed +- [x] intended changes have clear rationale +- [x] no accidental snapshot changes + +### tests executed — with proof + +| test suite | command run | result | proof | +|------------|-------------|--------|-------| +| types | `rhx git.repo.test --what types` | ✓ | exit 0, passed (20s) | +| lint | `rhx git.repo.test --what lint` | ✓ | exit 0, passed | +| format | `rhx git.repo.test --what format` | ✓ | exit 0, passed | +| unit | `rhx git.repo.test --what unit --mode apply` | ✓ | exit 0, 164 passed, 0 failed, 0 skipped (35 suites) | +| integration | `rhx git.repo.test --what integration --mode apply` | ✓ | exit 0, 96 passed, 0 failed, 0 skipped (15 suites) | +| acceptance | `rhx git.repo.test --what acceptance --against local --env test --mode apply` | ✓ | exit 0, 6 passed, 0 failed, 0 skipped (3 content + 3 parcel full-roundtrip) | + +- [x] every test command was run and its output observed +- [x] exit codes verified (all 0) +- [x] no tests skipped, mocked, or faked + +### contract output snapshot exhaustiveness + +| contract type | contract | positive path snapped? | negative path snapped? | edge cases snapped? | +|---------------|----------|------------------------|------------------------|---------------------| +| cli | `generate` (native arrays) | ✓ (home/home_version/view/upsert content + `--help`) | ✓ (invalid declaration `UserInputError` + declare-time throws: serial, nested, custom-check) | ✓ (empty array, NULL element/cell, value↔null transitions, enum-reject, mixed native+join) | + +- [x] positive path: CLI produces `varchar[]` columns + `<@` check + straight-through view + native-array write path in the upsert fn +- [x] negative path: invalid element/check/nested-array rejected fail-fast with snapshotted errors; CLI `--help` and invalid-declaration paths snapshotted +- [x] edge cases: empty array, NULL element, NULL cell→[], value↔null CVP transitions, mixed native+join-table, 13 primitive types — each with a roundtrip instance-data snapshot + +### peer-review convergence (l3 ladder terminal) + +- l1 (r1–r9): all approved (r8, r9 each 1 nitpick, within threshold) +- r11 (arch-defects, l3): approved (0/0) +- r10 (behavior-intent, l3): **approved** at i011 (0 blockers, 1 nitpick) after driving the + insatiable reviewer to convergence — the check-guard order-dependency blocker (i009) fixed at + the emission site, plus readme discoverability, time/timestamp/bytea assertions, enum NULL-element + rejection, per-type change-direction, acceptance content snapshot, and a dedicated util test. +- judge `reviewed?` (allow-blockers 0, allow-nitpicks 3): **allowed** ✓ + +### blockers +- none diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/blocker/5.1.execution.from_vision.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/blocker/5.1.execution.from_vision.md new file mode 100644 index 0000000..b72a591 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/blocker/5.1.execution.from_vision.md @@ -0,0 +1,64 @@ +# blocker — 5.1.execution.from_vision + +## what blocks me + +the peer-review ladder cannot run. 8 of the 9 level-1 reviewers died with the SAME infra error, +not a code verdict: + +``` +BrainChoiceNotFoundError: brain not found: fireworks/deepseek/v4-flash +``` + +those reviewers (mech-failhides, mech-decode-friction, arch-opport-decomposition, +arch-smell-scopeleaks, arch-hazards-maintenance, arch-hazards-behavior, behavior-intent-coverage, +ergo-friction-hazards) are configured to run on the brain `fireworks/deepseek/v4-flash`, which is +NOT in this environment's registered brain set. the available brains are only: + +- `anthropic/claude/*` (opus, sonnet, haiku, code) +- `xai/grok/*` (3, 3-mini, 4, 4-fast, 4.1-fast, code-fast-1) + +the `rhachet-brains-fireworksai@0.1.3` package is installed, but the fireworks brain never +registers — an absent fireworks credential / provider config. the one reviewer that ran on an +available brain, **r1 repo-rules, APPROVED the code (0 blockers, 0 nitpicks)**. + +this is a reviewer-infra malfunction, not a defect in the execution. the malfunctions are terminal +(they do not hold higher levels), but a malfunctioned reviewer still blocks passage — the stone +cannot pass while the rubrics that matter (mechanic, architect, behavior, ergonomist) have no brain +to run on. + +## what i tried + +1. **diagnosed the cause** — read the `.given.by_peer` stderr for the malfunctions; all 8 carry the + identical `BrainChoiceNotFoundError: brain not found: fireworks/deepseek/v4-flash`. +2. **checked driver-fixability** — searched the route dir and the repo for any config that selects + the reviewer brain (`fireworks`/`deepseek`/`v4-flash`). the only matches are inside the reviewer + ERROR LOGS; the brain choice is baked into the installed `rhachet-roles-bhrain` package's reviewer + definitions, not in any route/repo config i own. an edit of node_modules or a shared package is + not a real fix and is out of this task's scope. +3. **confirmed keyrack would not help** — the error is `brain not found` (the brain is not registered + at all), not a locked-credential error, so `rhx keyrack unlock` does not apply. +4. **verified the execution itself is complete and green** so the block is purely the reviewer infra: + - `types` passed + - `unit` 129 passed, 0 failed + - `integration` 83 passed, 0 failed (native round-trip + all extant join-table suites) + - `lint` passed, `format` passed + - all 8 self-reviews promised (coverage, conventions, adherence, role-standards) with real fixes + applied along the way (view coalesce, destructured discriminator, stale-header fix, test-idiom + fix, discriminator unit test). + +## what i need + +one of these, from the human (i cannot do either myself): + +1. **configure the fireworks credential / provider** so `fireworks/deepseek/v4-flash` registers as an + available brain, OR +2. **point the bhrain reviewers at an available brain** (`anthropic/claude/*` or `xai/grok/*`) in the + reviewer / route config. + +then i will re-drive the ladder with: + +``` +rhx route.stone.set --stone 5.1.execution.from_vision --as arrived +``` + +and converge each reviewer to a terminal verdict. the code is ready — r1 already approved it 0/0. diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/dream.uuid-arrays-to-native.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/dream.uuid-arrays-to-native.md new file mode 100644 index 0000000..86f6722 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/dream.uuid-arrays-to-native.md @@ -0,0 +1,48 @@ +# dream: convert uuid arrays to native `uuid[]` columns + +## .what + +teach `sql-schema-generator` to store `ARRAY_OF(UUID())` as a native `uuid[]` column on +the base/version table, instead of the current join-table model (a child `_uuid` table +plus an `array_order_index` column). + +## .why + +the `native-primitive-enum-array-columns` wish adds native array storage for +primitive/enum element arrays, but keeps uuid arrays on the join-table path, per its +stated scope. yet the decisive reason join tables exist for reference arrays — postgres +cannot put a foreign-key constraint on an array element [1] — does NOT apply to a bare +`UUID()`. a `UUID()` array carries no FK today (its child table holds a plain uuid column, +not a FK to a parent). so by the same logic that puts primitives on the native path, +`uuid[]` belongs there too. + +benefits: + +- one uniform array model: primitive + enum + uuid → native column; reference → join table +- faster single-table reads, with no `array_agg` join collapse in the view +- drops the `_uuid` child table and its `array_order_index` overhead + +## .cons to weigh + +- **migration cost** — uuid arrays are the one array kind with real prior use (the extant + integration tests exercise `_uuids`). a switch changes already-generated schemas and + forces a data migration for any consumer that uses them today — a break, not an additive + change. +- **two-way door** — the hydrated view presents either storage as `uuid[]`, so the move can + be made later with no DAO-visible contract change. no rush; low risk to defer. + +## .depends on + +- the `native-primitive-enum-array-columns` wish ships first — it builds the native array + machinery (DDL column path, upsert single-value write, view straight-through select, + value-equality change-detection) that this dream reuses. once that lands, a native branch + that also accepts a `UUID()` element is a contained addition. + +## .source + +- captured from the vision appendix (section C: "should uuid arrays move to native + `uuid[]` too?") of + `.behavior/v2026_07_24.native-primitive-enum-array-columns/1.vision.yield.md` +- [1] postgres / EDB — array element foreign keys are a long-established limitation; the + sanctioned workaround is a separate table with a FK constraint: + https://www.enterprisedb.com/blog/postgresql-93-development-array-element-foreign-keys diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/template.[feedback].v1.[given].by_human.md b/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/template.[feedback].v1.[given].by_human.md new file mode 100644 index 0000000..8575031 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/refs/template.[feedback].v1.[given].by_human.md @@ -0,0 +1,27 @@ +emit your response to the feedback into +- .behavior/v2026_07_24.native-primitive-enum-array-columns/$BEHAVIOR_REF_NAME.[feedback].v$FEEDBACK_VERSION.[taken].by_robot.md + +1. emit your response checklist +2. exec your response plan +3. emit your response checkoffs into the checklist + +--- + +first, bootup your mechanics briefs again + +./node_modules/.bin/rhachet roles boot --repo ehmpathy --role mechanic + +--- +--- +--- + + +# blocker.1 + +--- + +# nitpick.2 + +--- + +# blocker.3 diff --git a/.behavior/v2026_07_24.native-primitive-enum-array-columns/review/self/.gitignore b/.behavior/v2026_07_24.native-primitive-enum-array-columns/review/self/.gitignore new file mode 100644 index 0000000..c6959e2 --- /dev/null +++ b/.behavior/v2026_07_24.native-primitive-enum-array-columns/review/self/.gitignore @@ -0,0 +1,3 @@ +# ignore all self-review files +* +!.gitignore diff --git a/.claude/settings.json b/.claude/settings.json index b578d2d..76938aa 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -190,8 +190,7 @@ "Bash(gh search prs:*)", "Bash(gh search commits:*)", "Bash(gh api -X GET:*)", - "Bash(gh api --method GET:*)", - "Bash(source .agent/repo=.this/role=any/skills/use.apikeys.sh)" + "Bash(gh api --method GET:*)" ], "deny": [ "Bash(bash:*)", @@ -323,12 +322,6 @@ "timeout": 10, "author": "repo=bhuild/role=dispatcher" }, - { - "type": "command", - "command": "./node_modules/.bin/rhachet roles boot --role dreamer", - "timeout": 10, - "author": "repo=bhuild/role=dreamer" - }, { "type": "command", "command": "./node_modules/.bin/rhachet roles boot --repo ehmpathy --role architect", @@ -446,6 +439,12 @@ "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.check-permissions", "timeout": 5, "author": "repo=ehmpathy/role=mechanic" + }, + { + "type": "command", + "command": "./node_modules/.bin/rhx route.foreground.guard --mode hook", + "timeout": 5, + "author": "repo=bhrain/role=driver" } ] }, @@ -481,6 +480,17 @@ "author": "repo=ehmpathy/role=mechanic" } ] + }, + { + "matcher": "Write|Edit|Read", + "hooks": [ + { + "type": "command", + "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-shouted-readme", + "timeout": 5, + "author": "repo=ehmpathy/role=mechanic" + } + ] } ], "Stop": [ @@ -496,5 +506,10 @@ ] } ] + }, + "statusLine": { + "type": "command", + "command": "node -e \"import('rhachet-roles-bhrain/cli/route').then(m => m.routeStatusLine())\"", + "padding": 0 } } diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 343652b..ea7e93c 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -9,6 +9,7 @@ ignores: - rhachet - rhachet-brains-anthropic - rhachet-brains-xai + - rhachet-brains-fireworksai - rhachet-roles-bhrain - rhachet-roles-bhuild - rhachet-roles-ehmpathy diff --git a/.route/v2026_07_26.package.install/3.reason.for_rhachet-brains-fireworksai.yield.md b/.route/v2026_07_26.package.install/3.reason.for_rhachet-brains-fireworksai.yield.md new file mode 100644 index 0000000..b398edc --- /dev/null +++ b/.route/v2026_07_26.package.install/3.reason.for_rhachet-brains-fireworksai.yield.md @@ -0,0 +1,9 @@ +# reason: rhachet-brains-fireworksai@0.1.3 + +## package +- name: rhachet-brains-fireworksai +- version: 0.1.3 +- type: prep + +## reason +register the fireworks brain provider so the bhrain peer reviewers (which run on fireworks/deepseek/v4-flash) can execute; sibling providers rhachet-brains-anthropic and rhachet-brains-xai are already direct devDeps, but fireworks was only a transitive dep so its brain never registered diff --git a/package.json b/package.json index 117d7c0..2b67dba 100644 --- a/package.json +++ b/package.json @@ -119,12 +119,13 @@ "husky": "8.0.3", "jest": "30.2.0", "pg": "8.12.0", - "rhachet": "1.41.19", + "rhachet": "1.44.4", "rhachet-brains-anthropic": "0.4.1", + "rhachet-brains-fireworksai": "0.1.3", "rhachet-brains-xai": "0.3.3", - "rhachet-roles-bhrain": "0.29.0", - "rhachet-roles-bhuild": "0.21.15", - "rhachet-roles-ehmpathy": "1.35.13", + "rhachet-roles-bhrain": "0.31.3", + "rhachet-roles-bhuild": "0.21.31", + "rhachet-roles-ehmpathy": "1.38.5", "rhachet-roles-rhachet": "0.1.7", "simple-sha256": "1.0.0", "sql-formatter": "2.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e61e5d..9f19fc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,26 +130,29 @@ importers: specifier: 8.12.0 version: 8.12.0 rhachet: - specifier: 1.41.19 - version: 1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + specifier: 1.44.4 + version: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) rhachet-brains-anthropic: specifier: 0.4.1 - version: 0.4.1(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + version: 0.4.1(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) + rhachet-brains-fireworksai: + specifier: 0.1.3 + version: 0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) rhachet-brains-xai: specifier: 0.3.3 - version: 0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + version: 0.3.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) rhachet-roles-bhrain: - specifier: 0.29.0 - version: 0.29.0(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4))) + specifier: 0.31.3 + version: 0.31.3(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) rhachet-roles-bhuild: - specifier: 0.21.15 - version: 0.21.15(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)))(rhachet-roles-bhrain@0.29.0(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)))) + specifier: 0.21.31 + version: 0.21.31(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet-brains-xai@0.3.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet-roles-bhrain@0.31.3(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) rhachet-roles-ehmpathy: - specifier: 1.35.13 - version: 1.35.13(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + specifier: 1.38.5 + version: 1.38.5(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5)) rhachet-roles-rhachet: specifier: 0.1.7 - version: 0.1.7(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + version: 0.1.7(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) simple-sha256: specifier: 1.0.0 version: 1.0.0 @@ -203,12 +206,6 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -222,14 +219,6 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.5': - resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-s3@3.1067.0': - resolution: {integrity: sha512-3f64o9YWzwJ9WzMIC4JlUQiMOm7R/EtkIDyFdj8yaQXuh8SR9ezz2R32UMpvTlVMtpoPan3Uj8oveAHr2UeExw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.1041.0': resolution: {integrity: sha512-BJrZksR4qXCQODqsxOa9OPFtktumCJfJp99lpX17+z0tA8cR0EygKgvJs61zX80OHmGDY23tus7gXB36LinQAg==} engines: {node: '>=20.0.0'} @@ -238,40 +227,12 @@ packages: resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.46': - resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.972.48': - resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.972.53': - resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.52': - resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-node@3.972.55': - resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-process@3.972.46': - resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-sso@3.972.52': - resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.972.52': - resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + '@aws-sdk/core@3.977.0': + resolution: {integrity: sha512-w+iANjPGOj4fHxWeyjfRt+xeRX8BIOVStqdTcMebfeIJicTiBTMAbQurQuVfAxHpUfsoV+fWaEQvGpO6IWifLQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.30': - resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} + '@aws-sdk/credential-provider-sso@3.972.54': + resolution: {integrity: sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-host-header@3.972.21': @@ -286,34 +247,34 @@ packages: resolution: {integrity: sha512-q4H/CoOYrTbyAW0d9RrHf9kTYKVXpAwoK0VEy3UT2Asad+6aa6vzQgz35dh20tRA7zlEo/Nsyjy9PVlHgdq0Vg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.51': - resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.50': resolution: {integrity: sha512-5JIDcDFWy3DUW95sOlXLbeb7RkGFUcNh1QidKsznqtlm5YGsXP0EGWaqzxBTvVmOhqKs2RmNmI6w9V/5dS3CLQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.20': - resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + '@aws-sdk/nested-clients@3.997.35': + resolution: {integrity: sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.972.24': resolution: {integrity: sha512-WY6uVMsq0EvxY4BcYhZmG2Ivd1EzNvZAqsXFlL3pTPMG0P4J83TYVQIs8P0nd5lc+Bp3llrYwggruvXzrfUtsQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.34': - resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + '@aws-sdk/signature-v4-multi-region@3.996.42': + resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1066.0': - resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + '@aws-sdk/token-providers@3.1071.0': + resolution: {integrity: sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.12': resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.996.19': resolution: {integrity: sha512-W79LutZYmCV1WGe0UVGALMna3xLGP3wv2zLzrBEgc73CtzxKBxb5IpMedbS6Ej80LCknSgqFjsTtECG0IInckw==} engines: {node: '>=20.0.0'} @@ -333,10 +294,18 @@ packages: resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -661,6 +630,10 @@ packages: resolution: {integrity: sha512-gx6ovvRLdv8WfLeIpmjeYN9rl1gE4PcDmlcxFCAcl3PmRxhLSp2YlQPtiTUNxqGXw3KBXnhu83HcMwkQpj56ig==} engines: {node: '>=8.0.0'} + '@ehmpathy/as-command@1.0.5': + resolution: {integrity: sha512-GKR67ZHmf2pJHxVL5ebV2w2FRr4zoySlHjAk7IW+feQxmLvZ7orJqD7No//lFcHmUgOYGw4Hg5vB8c693k9Odw==} + engines: {node: '>=8.0.0'} + '@ehmpathy/error-fns@1.0.2': resolution: {integrity: sha512-v3aJIqUvD9a3drx1pyS8La+9u9WTTvNE35NksiD4Oo3VanNe8Rmue/atRHPg4nNYQ/xPv4+RoqC+OBj6cAY8VA==} engines: {node: '>=8.0.0'} @@ -1694,14 +1667,18 @@ packages: resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.8': - resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} + '@smithy/core@3.29.8': + resolution: {integrity: sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA==} engines: {node: '>=18.0.0'} '@smithy/fetch-http-handler@5.4.6': resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.10': + resolution: {integrity: sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.3.6': resolution: {integrity: sha512-lIZyQ7gDxURrnfkjalM0lKmDnfZYuPzNBYlkza3czPTQNVYsg4e0o90Zx/RpxhamKKOGsQGCsopp0ULsJqltNQ==} engines: {node: '>=18.0.0'} @@ -1742,6 +1719,10 @@ packages: resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.10': + resolution: {integrity: sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.4.6': resolution: {integrity: sha512-H6S7NyaaL+7qO8kIL7VQ7KyrGnKXdllGzJqvtp3hvDen25UOydKV51qGDVK0UciW125jV3CoLJQy/ihc0OEC6A==} engines: {node: '>=18.0.0'} @@ -1750,6 +1731,10 @@ packages: resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.9': + resolution: {integrity: sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.13.6': resolution: {integrity: sha512-tAf35/JW/DvMlACcazcoIOKOV0JBqyOvxjPTEME9W+m9wLcE0G1rwADc7Ntu38rY5C9OH8jZjpo4tbtjmIjEBQ==} engines: {node: '>=18.0.0'} @@ -1758,6 +1743,10 @@ packages: resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} engines: {node: '>=18.0.0'} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.3.6': resolution: {integrity: sha512-9MRJzwUrlswwHogOR7raDcykuzojZn74qGdQdbEQLVaixlvJuMiIT0g/CejKcmAIgrUVs8brBrnGtmYmBc0iuA==} engines: {node: '>=18.0.0'} @@ -2868,6 +2857,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2880,10 +2873,6 @@ packages: resolution: {integrity: sha512-Bcjk4hbOrl35Q1a9y/3HMchAwJlJu3yCvODwjWDPYJPgvDrTadTAIlLwLYVMyJVfGlStZgqHH9B8XmDBjNYSDg==} engines: {node: '>=8.0.0'} - domain-objects@0.25.2: - resolution: {integrity: sha512-CcV0IhH9byG84uDx6g+h63DmI1YFC/wfwMGEHQ4OKsAOpX8/kSwcJx5ulQLG4XR7c5ZwMu+hFkQSyRjnkWXoGg==} - engines: {node: '>=8.0.0'} - domain-objects@0.31.0: resolution: {integrity: sha512-x7G/Ig4vtvvL/Z7z+b2D2hwM+ZCr/NBKmujbKi9JA07KEVhI/yzHUPtAfiS6pkIODGQuEEqYicY/HMsUECimKw==} engines: {node: '>=8.0.0'} @@ -2900,6 +2889,10 @@ packages: resolution: {integrity: sha512-6i4w5gIV6lgOw9uEzETYW9Ejbm7H1OyQAwjE5zX6yPxUyTXT9CYAmrRuAIEmX83hy+YfK7gsh+6+6Ew7vKOCDA==} engines: {node: '>=8.0.0'} + domain-objects@0.31.14: + resolution: {integrity: sha512-tGl3Fv0ZYGGgmoPy4s5F0XLdldjRcaZ230m26C8zfQN7yWYe7gCPueI49IgxODpEbDFFXYugcbDyldL889WDqg==} + engines: {node: '>=8.0.0'} + domain-objects@0.31.3: resolution: {integrity: sha512-2mLwdU89tZn8dWaBWOi+bXAENB9g2fSHiDbMMboC7er3aesupqmZCJTCwiSYU/98Hj42sCoLLTXMpPgbOQL1Nw==} engines: {node: '>=8.0.0'} @@ -3309,6 +3302,10 @@ packages: resolution: {integrity: sha512-LwxvaMlCWocpecOx0eAjq4pDE4VT9NjGgowfrsvUk3KY1s0Bz3Ab+N7wVskcycYWzFVfmI3oxujWwnz9ZDuqeA==} engines: {node: '>=8.0.0'} + hash-fns@3.0.0: + resolution: {integrity: sha512-JNwsi2z19Ge6Vjq2bwfmVnSrUkiRs1fUEiFaBPwRo8AQ9Vo/q/C+r0fj2MsRnpzY6McWWMH1HvD1gKVAUGfaQg==} + engines: {node: '>=8.0.0'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -3316,10 +3313,6 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} - helpful-errors@1.3.8: - resolution: {integrity: sha512-iGdxmyiOcGPWmAPZf6bNmHLlh2eNOoTTNO90SbUfh0Kz80/AO3JugQxMI9PZwLCYlF5ivS6SS/5pxm8p3xZnqQ==} - engines: {node: '>=8.0.0'} - helpful-errors@1.5.3: resolution: {integrity: sha512-d1mwEIfBVUfMJqORl6/lzGMPHL72wgk8FF71ULOY3SQ8yYeqPPStFmY85IOAowtkfm1pdpdAY4hsPRCz1cGcQw==} engines: {node: '>=8.0.0'} @@ -4611,10 +4604,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rhachet-artifact-git@1.1.0: - resolution: {integrity: sha512-OZtfH5+4/nj4kSX0pnamIT54YVIKTNhavBqiYpCK3978sGGagYo7AwXwYAjQav9+Vjhtk5b73RJUQN0bSe3a+g==} - engines: {node: '>=8.0.0'} - rhachet-artifact-git@1.1.3: resolution: {integrity: sha512-l8v8sdRW44w4CCFjWIydjk6BgyaQ+HCH00s/ZtuoxdHSYRU++0sxHiDJzgSQyYCHy/egSiD0OCQLbjqcDCS20Q==} engines: {node: '>=8.0.0'} @@ -4623,10 +4612,6 @@ packages: resolution: {integrity: sha512-Axg9paHpCu7Bx9kdlktqQktqxja+cGNeAmQGoVWaddLt0CgSxUG9hIU5RAfbvP3hMvOs5nPtqGvvpsk1mX89nA==} engines: {node: '>=8.0.0'} - rhachet-artifact@1.0.0: - resolution: {integrity: sha512-dNGjHYykoxC5vljtdWL/VhZmPU89gVS9gGI5yHPzJiwNtk6hMZLtQe1B0hpZu+tu95+eerU4jNPklTj2Sdg5Tg==} - engines: {node: '>=8.0.0'} - rhachet-artifact@1.0.1: resolution: {integrity: sha512-BCD7rwyArOn9k3AwDocv0UrgkNDmJ/goPAROCLyMx06n72Xhq8nO9RAVVqhdggIjyX8lI0Qriul8tgUJ+3zoIA==} engines: {node: '>=8.0.0'} @@ -4641,27 +4626,36 @@ packages: peerDependencies: rhachet: '>=1.21.4' + rhachet-brains-fireworksai@0.1.3: + resolution: {integrity: sha512-RBDPyJkhM+kqKBdUnOKMARAx9jKjdEqNEJs/00B4FePAt8CSsJ18aU0tW1qHQzq1GU3kKX+JI/PwEtOdJNxEiw==} + engines: {node: '>=8.0.0'} + peerDependencies: + rhachet: '>=1.21.4' + rhachet-brains-xai@0.3.3: resolution: {integrity: sha512-ZCzPTCVACUbl/qe29j/YA+M5tmKihD7kMgOL1Gv/09pRUyZ7I+YXD/9Mp2rF3V5GIJlWgzCh/AYz/yVDC8HaAw==} engines: {node: '>=8.0.0'} peerDependencies: rhachet: '>=1.21.4' - rhachet-roles-bhrain@0.29.0: - resolution: {integrity: sha512-o5qXeM/9feWK9hZmUhgvBLROt5XkXZZnVIzfpyty3BVidk06IhhrpHAFPY8VXnJb3dpKTe47EsJgM7NEqNSOLQ==} + rhachet-roles-bhrain@0.31.3: + resolution: {integrity: sha512-dAbKuBSe2Id0EhaTjXBFL5QDI4TvpjIMG4ob7DrkPGyjvBtkeuhROCehYxKpm9PWYVvPaVVKhCiiByxA6f5l/A==} engines: {node: '>=8.0.0'} peerDependencies: - rhachet-brains-xai: '>=0.3.0' + rhachet: '>=1.41.21' + rhachet-brains-fireworksai: '>=0.1.2' - rhachet-roles-bhuild@0.21.15: - resolution: {integrity: sha512-Eqnq1hewXSc75v5AI5w7B+heujhZH0B+4PV4qViB3P06UdgJgpM0SDKF0NxSsXGi80v1TsW3y1wJf/1CvXWYOQ==} + rhachet-roles-bhuild@0.21.31: + resolution: {integrity: sha512-HsEzZZE663UmgKuYCzuBUGwwjqe2MyRQ9xApFJ4DPTwNkLavZwLxvUxakzlu1bXBXI39hqmAWVDpiQjlMX5FaA==} engines: {node: '>=18.0.0'} peerDependencies: + rhachet: '>=1.43.1' + rhachet-brains-fireworksai: '>=0.1.3' rhachet-brains-xai: '>=0.3.3' - rhachet-roles-bhrain: '>=0.12.1' + rhachet-roles-bhrain: '>=0.30.4' - rhachet-roles-ehmpathy@1.35.13: - resolution: {integrity: sha512-p+QYhPJHGh+KpMKa9b+9FXM/aFSGzwPdOE/hzytv9RJjlXDDWlWXKtdhyB8AlpX+ttj7fpSS6rjScFNJgpsajQ==} + rhachet-roles-ehmpathy@1.38.5: + resolution: {integrity: sha512-HUX/lp6YPpfgXXP9yMofsOOS82uF90bgy3h18+7ewz8qcVW0MoXJKG0cbJBHtHryyqLqA0koV5gqcVQvEyaitA==} engines: {node: '>=8.0.0'} rhachet-roles-rhachet@0.1.7: @@ -4670,8 +4664,8 @@ packages: peerDependencies: rhachet: '>=1.0.0' - rhachet@1.41.19: - resolution: {integrity: sha512-y3EtM28nAhp1EP9EXdGGqGMRsqml5EPk9igrnqiCUBl3L3mMx79AJtRAYcxdbAHHI4wzSVHx1jAmUc1KZyJ+IA==} + rhachet@1.44.4: + resolution: {integrity: sha512-PESEfb5+Y4tzo/zACo3rXxkUJ6xqM9GEfe7roQweM+bsvX2MuNTr5p/JZcOXMhDN0hcscelcEVqfW82l8HPdLw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: @@ -4717,6 +4711,10 @@ packages: resolution: {integrity: sha512-SH3TaoaJFzfAtqs3eG1j5IuHJkeEW5rKUPIjIN+ZorLAyJLHItQGnsgwHk76v25GtLtpT9IqfAcqK4vFWdiw+w==} engines: {node: '>=6.0.0'} + sdk-logs@0.9.2: + resolution: {integrity: sha512-QJWdd2EfpaXVpgRHzX9H4ZMysbhX1dGn5QzO7JMUGh7zB0FAUnizdvZETWyJk0ZDkNhJw6ne+5pFlFTWr1H0Zg==} + engines: {node: '>=8.0.0'} + seedrandom@3.0.5: resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} @@ -4807,10 +4805,6 @@ packages: resolution: {integrity: sha512-z/uFEnbxblAa0j3PmEwEZyKV73YwEG0vEgcFkjy4ppjlPhaL5oi6G5DvoBua5aFYEp8kjDOy4OpLPux4J6PWqA==} engines: {node: '>=8.0.0'} - simple-on-disk-cache@1.7.3: - resolution: {integrity: sha512-N5ILAhtiYSLVL2UyzVFfzcd/nWzgxPS/ksSYwUChDGS4HwKh6QR+18QwUtQ78oV/QXGDiXrtuNQxdTbwYrkfcw==} - engines: {node: '>=8.0.0'} - simple-sha256@1.0.0: resolution: {integrity: sha512-kGttoS50Y53Wu45iir77VOO7aR2obk1Jjalwz7HiMWPwzWq781h3EtLjpCP4DoDYM5ppncv0f1juNX+hjzmXsg==} @@ -5118,6 +5112,10 @@ packages: resolution: {integrity: sha512-9/E7WpYVjJxIisH2lSpHnqwetk6SxVx2M+EI1dv0SMp3ztB6Qg4zrYwURepJHyt/zNRTb7XoGoo1H5Bxq9wI5Q==} engines: {node: '>=8.0.0'} + type-fns@1.21.3: + resolution: {integrity: sha512-aB66HI+uWPsU/XuCHUY2ShCdnNVyDQT2F3OSc2AyezMqQSIC9ww8UsnGFxvfAh54ku5PL4gHtBMueGWanbkSAA==} + engines: {node: '>=8.0.0'} + typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} engines: {node: '>=14.17'} @@ -5241,12 +5239,12 @@ packages: resolution: {integrity: sha512-qOVl6RKcp8juicmaaW9H06+OnQtkK8dhl3EHWupNjp5BPuyhMXDIZjWNozoyn/Xq3yu+QhOWvik7HTr2A+XW3Q==} engines: {node: '>=8.0.0'} - with-simple-caching@0.14.2: - resolution: {integrity: sha512-jG76kjJBeZnPUPTY5v/031ropCAIGrIUYMOkk+ZfcrpzCMgxBPLB2gvINfY5alggn8qHntoYCeMqIzIlf1DDmw==} + with-simple-cache@0.16.2: + resolution: {integrity: sha512-JuNpP+gLUeZ7XWWmHYD3fSnoHdcL7ttDomEYUgA7Rfz40hG+IuaxX5SQcHUFYN+HfVFf1Rewb/KWCM+FvkONIA==} engines: {node: '>=8.0.0'} - with-simple-caching@0.14.4: - resolution: {integrity: sha512-NRFgFUcMIDnXbN8DXZv21/bCrZD/bA2aINz2m7ddgUE958D95s4B6axIG85kXKjr0DLGYZDtqRzCZoGbjIZylg==} + with-simple-caching@0.14.2: + resolution: {integrity: sha512-jG76kjJBeZnPUPTY5v/031ropCAIGrIUYMOkk+ZfcrpzCMgxBPLB2gvINfY5alggn8qHntoYCeMqIzIlf1DDmw==} engines: {node: '>=8.0.0'} word-wrap@1.2.5: @@ -5415,22 +5413,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - tslib: 2.8.1 - - '@aws-crypto/crc32c@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - tslib: 2.8.1 - - '@aws-crypto/sha1-browser@5.2.0': - dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 - '@smithy/util-utf8': 2.3.0 + '@aws-sdk/types': 3.974.2 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -5455,38 +5438,10 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.974.2 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.5': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.1067.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-flexible-checksums': 3.974.30 - '@aws-sdk/middleware-sdk-s3': 3.972.51 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - '@aws-sdk/client-sso@3.1041.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5539,95 +5494,27 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.46': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.48': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.972.53': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-login': 3.972.52 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-login@3.972.52': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-node@3.972.55': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-ini': 3.972.53 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.46': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.972.52': + '@aws-sdk/core@3.977.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/token-providers': 3.1066.0 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.8 + '@smithy/signature-v4': 5.6.9 + '@smithy/types': 4.16.1 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.52': + '@aws-sdk/credential-provider-sso@3.972.54': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/token-providers': 3.1071.0 + '@aws-sdk/types': 3.974.2 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.30': - dependencies: - '@aws-sdk/checksums': 3.1000.5 - tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.21': dependencies: '@aws-sdk/core': 3.974.20 @@ -5643,31 +5530,20 @@ snapshots: '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.51': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.50': dependencies: '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.20': + '@aws-sdk/nested-clients@3.997.35': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.0 + '@aws-sdk/signature-v4-multi-region': 3.996.42 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/fetch-http-handler': 5.6.10 + '@smithy/node-http-handler': 4.9.10 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/region-config-resolver@3.972.24': @@ -5675,18 +5551,18 @@ snapshots: '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.34': + '@aws-sdk/signature-v4-multi-region@3.996.42': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.9 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1066.0': + '@aws-sdk/token-providers@3.1071.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/types': 3.974.2 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 @@ -5696,6 +5572,11 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.996.19': dependencies: '@aws-sdk/core': 3.974.20 @@ -5722,8 +5603,15 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6114,6 +6002,11 @@ snapshots: bottleneck: 2.19.5 date-fns: 3.6.0 + '@ehmpathy/as-command@1.0.5': + dependencies: + bottleneck: 2.19.5 + date-fns: 3.6.0 + '@ehmpathy/error-fns@1.0.2': dependencies: type-fns: 0.9.0 @@ -7152,10 +7045,9 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.8': + '@smithy/core@3.29.8': dependencies: - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/fetch-http-handler@5.4.6': @@ -7164,6 +7056,12 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.10': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/hash-node@4.3.6': dependencies: '@smithy/core': 3.24.6 @@ -7214,6 +7112,12 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/node-http-handler@4.9.10': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/protocol-http@5.4.6': dependencies: '@smithy/core': 3.24.6 @@ -7225,6 +7129,12 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/signature-v4@5.6.9': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/smithy-client@4.13.6': dependencies: '@smithy/core': 3.24.6 @@ -7235,6 +7145,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.3.6': dependencies: '@smithy/core': 3.24.6 @@ -8434,6 +8348,8 @@ snapshots: diff@4.0.4: optional: true + diff@9.0.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -8442,15 +8358,6 @@ snapshots: domain-glossary-procedure@1.0.0: {} - domain-objects@0.25.2: - dependencies: - '@ehmpathy/error-fns': 1.3.7 - '@types/joi': 17.2.3 - '@types/yup': 0.29.14 - change-case: 4.1.2 - cross-sha256: 1.2.0 - type-fns: 1.21.2 - domain-objects@0.31.0: dependencies: '@types/joi': 17.2.3 @@ -8481,6 +8388,13 @@ snapshots: type-fns: 1.21.2 uuid-fns: 1.1.3 + domain-objects@0.31.14: + dependencies: + change-case: 4.1.2 + helpful-errors: 1.7.3 + type-fns: 1.21.2 + uuid-fns: 1.1.3 + domain-objects@0.31.3: dependencies: change-case: 4.1.2 @@ -8490,21 +8404,19 @@ snapshots: type-fns: 1.21.0 uuid-fns: 1.1.3 - domain-objects@0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): + domain-objects@0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4): dependencies: change-case: 4.1.2 domain-objects: 0.31.3 helpful-errors: 1.5.3 joi: 17.4.0 - rhachet: 1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) - rhachet-roles-ehmpathy: 1.35.13(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) + rhachet-roles-ehmpathy: 1.38.5(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5)) type-fns: 1.21.0 uuid-fns: 1.1.3 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - ws - zod domain-objects@0.31.9: @@ -8990,6 +8902,12 @@ snapshots: domain-glossaries: 1.0.0 type-fns: 1.21.0 + hash-fns@3.0.0: + dependencies: + '@noble/hashes': 2.0.1 + domain-glossaries: 1.0.0 + type-fns: 1.21.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -8999,10 +8917,6 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 - helpful-errors@1.3.8: - dependencies: - type-fns: 1.17.0 - helpful-errors@1.5.3: dependencies: type-fns: 1.20.2 @@ -10447,18 +10361,6 @@ snapshots: reusify@1.1.0: {} - rhachet-artifact-git@1.1.0: - dependencies: - '@ehmpathy/uni-time': 1.8.1 - as-procedure: 1.1.7 - domain-objects: 0.25.2 - find-up: 5.0.0 - hash-fns: 1.1.0 - helpful-errors: 1.3.8 - rhachet-artifact: 1.0.0 - serde-fns: 1.3.0 - type-fns: 1.19.0 - rhachet-artifact-git@1.1.3: dependencies: '@ehmpathy/uni-time': 1.8.1 @@ -10483,11 +10385,6 @@ snapshots: serde-fns: 1.3.0 type-fns: 1.21.0 - rhachet-artifact@1.0.0: - dependencies: - as-procedure: 1.1.7 - domain-objects: 0.25.2 - rhachet-artifact@1.0.1: dependencies: as-procedure: 1.1.7 @@ -10500,7 +10397,7 @@ snapshots: domain-objects: 0.31.9 helpful-errors: 1.5.3 - rhachet-brains-anthropic@0.4.1(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)): + rhachet-brains-anthropic@0.4.1(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): dependencies: '@anthropic-ai/claude-agent-sdk': 0.1.76(zod@4.3.4) '@anthropic-ai/sdk': 0.71.2(zod@4.3.4) @@ -10508,19 +10405,32 @@ snapshots: helpful-errors: 1.5.3 iso-price: 1.1.1(domain-objects@0.31.9) iso-time: 1.11.1 - rhachet: 1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) rhachet-artifact: 1.0.1 rhachet-artifact-git: 1.1.5 type-fns: 1.21.0 zod: 4.3.4 - rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)): + rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): dependencies: domain-objects: 0.31.9 helpful-errors: 1.5.3 iso-price: 1.1.1(domain-objects@0.31.9) openai: 5.8.2(zod@4.3.4) - rhachet: 1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) + rhachet-artifact: 1.0.1 + rhachet-artifact-git: 1.1.5 + zod: 4.3.4 + transitivePeerDependencies: + - ws + + rhachet-brains-xai@0.3.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): + dependencies: + domain-objects: 0.31.9 + helpful-errors: 1.5.3 + iso-price: 1.1.1(domain-objects@0.31.9) + openai: 5.8.2(zod@4.3.4) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) rhachet-artifact: 1.0.1 rhachet-artifact-git: 1.1.5 type-fns: 1.21.0 @@ -10528,14 +10438,16 @@ snapshots: transitivePeerDependencies: - ws - rhachet-roles-bhrain@0.29.0(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4))): + rhachet-roles-bhrain@0.31.3(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): dependencies: '@ehmpathy/as-command': 1.0.3 '@ehmpathy/uni-time': 1.8.1 archiver: 7.0.1 as-procedure: 1.1.7 + diff: 9.0.0 domain-objects: 0.31.9 - fast-glob: 3.3.3 + globby: 11.1.0 + hash-fns: 3.0.0 helpful-errors: 1.5.3 inquirer: 12.7.0(@types/node@22.15.21) iso-price: 1.1.1(domain-objects@0.31.9) @@ -10543,9 +10455,10 @@ snapshots: js-yaml: 4.1.1 npm: 11.7.0 openai: 5.8.2(zod@4.3.4) - rhachet-artifact: 1.0.0 - rhachet-artifact-git: 1.1.0 - rhachet-brains-xai: 0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) + rhachet-artifact: 1.0.3 + rhachet-artifact-git: 1.1.5 + rhachet-brains-fireworksai: 0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) serde-fns: 1.2.0 simple-in-memory-cache: 0.4.0 type-fns: 1.21.0 @@ -10559,60 +10472,50 @@ snapshots: - react-native-b4a - ws - rhachet-roles-bhuild@0.21.15(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)))(rhachet-roles-bhrain@0.29.0(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)))): + rhachet-roles-bhuild@0.21.31(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet-brains-xai@0.3.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet-roles-bhrain@0.31.3(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): dependencies: domain-objects: 0.31.9 emoji-space-shim: 0.0.0 helpful-errors: 1.7.3 iso-time: 1.11.3 - rhachet-brains-xai: 0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) - rhachet-roles-bhrain: 0.29.0(@types/node@22.15.21)(rhachet-brains-xai@0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4))) - test-fns: 1.15.0(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) + rhachet-brains-fireworksai: 0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) + rhachet-brains-xai: 0.3.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) + rhachet-roles-bhrain: 0.31.3(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.3(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)))(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)) + test-fns: 1.15.0(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) zod: 4.3.4 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - ws - rhachet-roles-ehmpathy@1.35.13(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)): + rhachet-roles-ehmpathy@1.38.5(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5)): dependencies: '@atjsh/llmlingua-2': 2.0.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(js-tiktoken@1.0.21) - '@ehmpathy/as-command': 1.0.3 - '@ehmpathy/uni-time': 1.8.1 - as-procedure: 1.1.7 - domain-objects: 0.31.9 + '@ehmpathy/as-command': 1.0.5 + as-procedure: 1.1.11 + domain-objects: 0.31.14 fast-glob: 3.3.3 helpful-errors: 1.7.3 - inquirer: 12.7.0(@types/node@22.15.21) js-tiktoken: 1.0.21 - openai: 5.8.2(zod@4.3.4) - rhachet-artifact: 1.0.0 - rhachet-artifact-git: 1.1.0 - rhachet-brains-xai: 0.3.3(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) - rhachet-roles-rhachet: 0.1.7(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)) - serde-fns: 1.2.0 - simple-in-memory-cache: 0.4.0 - simple-on-disk-cache: 1.7.3 - type-fns: 1.21.0 - with-simple-cache: 0.15.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) - with-simple-caching: 0.14.4 + rhachet-artifact: 1.0.3 + rhachet-artifact-git: 1.1.5 + simple-on-disk-cache: 1.7.10 + type-fns: 1.21.3 + with-simple-cache: 0.16.2 wrapper-fns: 1.1.7 zod: 4.3.4 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - rhachet - - ws - rhachet-roles-rhachet@0.1.7(rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4)): + rhachet-roles-rhachet@0.1.7(rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4)): dependencies: - rhachet: 1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + rhachet: 1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) - rhachet@1.41.19(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): + rhachet@1.44.4(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4): dependencies: '@aws-sdk/client-sso': 3.1041.0 + '@aws-sdk/credential-provider-sso': 3.972.54 '@noble/curves': 2.0.1 '@noble/hashes': 2.0.1 '@octokit/auth-app': 8.2.0 @@ -10635,19 +10538,18 @@ snapshots: picomatch: 4.0.4 rhachet-artifact: 1.0.3 rhachet-artifact-git: 1.1.5 + sdk-logs: 0.9.2 serde-fns: 1.3.1 simple-in-memory-cache: 0.4.0 simple-log-methods: 0.6.9 type-fns: 1.21.0 uuid-fns: 1.0.1 - with-simple-cache: 0.15.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + with-simple-cache: 0.15.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) yaml: 2.8.2 zod: 4.3.4 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - ws roarr@2.15.4: dependencies: @@ -10693,6 +10595,15 @@ snapshots: invariant: 2.2.4 lodash: 4.17.21 + sdk-logs@0.9.2: + dependencies: + '@ehmpathy/error-fns': 1.3.7 + domain-glossary-procedure: 1.0.0 + domain-objects: 0.31.13 + helpful-errors: 1.7.3 + iso-time: 1.11.7 + type-fns: 1.21.2 + seedrandom@3.0.5: {} semver-compare@1.0.0: {} @@ -10828,18 +10739,6 @@ snapshots: type-fns: 1.21.2 with-bottleneck: 0.1.1 - simple-on-disk-cache@1.7.3: - dependencies: - '@aws-sdk/client-s3': 3.1067.0 - '@ehmpathy/uni-time': 1.10.0 - bottleneck: 2.19.5 - domain-objects: 0.31.0 - hash-fns: 1.1.1 - helpful-errors: 1.5.3 - serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.2 - type-fns: 1.21.0 - simple-sha256@1.0.0: {} slash@3.0.0: {} @@ -11004,17 +10903,15 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 - test-fns@1.15.0(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): + test-fns@1.15.0(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4): dependencies: - domain-objects: 0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + domain-objects: 0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) helpful-errors: 1.5.3 iso-time: 1.11.3 uuid: 10.0.0 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - ws - zod test-fns@1.15.7: @@ -11171,6 +11068,11 @@ snapshots: domain-objects: 0.31.10 helpful-errors: 1.7.3 + type-fns@1.21.3: + dependencies: + domain-objects: 0.31.10 + helpful-errors: 1.7.3 + typescript@5.4.5: {} typescript@5.9.3: {} @@ -11311,10 +11213,10 @@ snapshots: simple-on-disk-cache: 1.7.10 type-fns: 1.21.2 - with-simple-cache@0.15.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): + with-simple-cache@0.15.3(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4): dependencies: '@ehmpathy/uni-time': 1.10.0 - domain-objects: 0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + domain-objects: 0.31.7(@huggingface/transformers@4.2.0)(@tensorflow/tfjs@4.22.0(encoding@0.1.13)(seedrandom@3.0.5))(zod@4.3.4) helpful-errors: 1.5.3 procedure-fns: 1.0.1 serde-fns: 1.3.1 @@ -11324,27 +11226,27 @@ snapshots: transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - - '@types/node' - - ws - zod - with-simple-caching@0.14.2: + with-simple-cache@0.16.2: dependencies: - '@ehmpathy/uni-time': 1.10.0 + domain-objects: 0.31.13 + helpful-errors: 1.7.3 + iso-time: 1.11.7 + procedure-fns: 1.0.1 serde-fns: 1.3.1 simple-in-memory-cache: 0.4.2 simple-on-disk-cache: 1.7.10 - type-fns: 1.21.2 - visualogic: 1.3.3 + type-fns: 1.21.3 - with-simple-caching@0.14.4: + with-simple-caching@0.14.2: dependencies: '@ehmpathy/uni-time': 1.10.0 - procedure-fns: 1.0.1 serde-fns: 1.3.1 simple-in-memory-cache: 0.4.2 simple-on-disk-cache: 1.7.10 type-fns: 1.21.2 + visualogic: 1.3.3 word-wrap@1.2.5: {} diff --git a/readme.md b/readme.md index db9fc56..a76b59d 100644 --- a/readme.md +++ b/readme.md @@ -24,7 +24,7 @@ Declarative relational database schema generation. Ensure best practices are fol - to interact with data - entity static table - entity version table (if updatable properties exist, for an insert-only / event-driven design, per temporal database design) - - entity mapping tables (if array properties exist, to define the many-to-many relationship) + - entity mapping tables (if reference/uuid array properties exist, to define the many-to-many relationship; primitive/enum arrays become a native array column on the row instead) - upsert function (for idempotent inserts) - a `_current` view, to abstract away the versioning pattern and mapping tables - to improve performance @@ -97,7 +97,9 @@ Consequently, by utilizing the schema generator: built: prop.TIMESTAMPTZ(), bedrooms: prop.INT(), bathrooms: prop.INT(), - photo_ids: { ...prop.ARRAY_OF(prop.REFERENCES(photo)), updatable: true }, // array of photos + photo_ids: { ...prop.ARRAY_OF(prop.REFERENCES(photo)), updatable: true }, // array of photos -> join table (element-level fk) + tags: { ...prop.ARRAY_OF(prop.VARCHAR()), updatable: true }, // array of primitives -> native varchar[] column on the row + statuses: { ...prop.ARRAY_OF(prop.ENUM(['FOR_SALE', 'PENDING', 'SOLD'])), updatable: true }, // array of enums -> native varchar[] column + array-membership check }, unique: ['name', 'owner_id'], }); diff --git a/src/contract/.test.assets/codegen.sql.schema.acceptance.invalid.yml b/src/contract/.test.assets/codegen.sql.schema.acceptance.invalid.yml new file mode 100644 index 0000000..5c0cca5 --- /dev/null +++ b/src/contract/.test.assets/codegen.sql.schema.acceptance.invalid.yml @@ -0,0 +1,6 @@ +language: postgres +dialect: 10.7 +declarations: domain.acceptance.invalid.ts +generates: + sql: + to: generated.invalid diff --git a/src/contract/.test.assets/domain.acceptance.invalid.ts b/src/contract/.test.assets/domain.acceptance.invalid.ts new file mode 100644 index 0000000..e123ca8 --- /dev/null +++ b/src/contract/.test.assets/domain.acceptance.invalid.ts @@ -0,0 +1,16 @@ +// for acceptance tests: import from compiled dist (like a real user's node_modules) +// this fixture is DELIBERATELY invalid: it declares a native array of a serial pseudo-type, +// which sql-schema-generator rejects at declare time. it exercises the CLI's negative path. +import { Entity, prop } from '../../../dist/contract/module.js'; + +const broken = new Entity({ + name: 'broken', + properties: { + name: prop.VARCHAR(255), + // serial pseudo-types have no valid postgres array form -> ARRAY_OF throws at declare time + counters: prop.ARRAY_OF(prop.BIGSERIAL()), + }, + unique: ['name'], +}); + +export const entities = [broken]; diff --git a/src/contract/.test.assets/domain.acceptance.ts b/src/contract/.test.assets/domain.acceptance.ts index 325e17a..22c09ed 100644 --- a/src/contract/.test.assets/domain.acceptance.ts +++ b/src/contract/.test.assets/domain.acceptance.ts @@ -40,6 +40,11 @@ const home = new Entity({ built: prop.TIMESTAMPTZ(), bedrooms: prop.INT(), bathrooms: prop.INT(), + tags: prop.ARRAY_OF(prop.VARCHAR()), // native primitive array -> text[] column, not a join table + amenities: { + ...prop.ARRAY_OF(prop.ENUM(['POOL', 'WIFI', 'PARKING'])), // native enum array -> varchar[] + membership check + updatable: true, // a home's amenities change over time + }, photo_ids: { ...prop.ARRAY_OF(prop.REFERENCES(photo)), updatable: true, // the photos of a home change over time @@ -47,6 +52,25 @@ const home = new Entity({ }, unique: ['name', 'host_ids'], }); +// a self-contained native-array entity with no reference-array dependencies, so its whole +// generated graph (static + version + cvp + upsert + view) applies cleanly to a real postgres. +// this is the vehicle for the acceptance-level full round-trip: it exercises the identical +// native-array codegen paths as `home` (static primitive array, static numeric array, updatable +// enum array), but without home's transitive `user` table (a reserved word the generator emits +// unquoted), which would block a literal apply of home's own graph. +const parcel = new Entity({ + name: 'parcel', + properties: { + apn: prop.VARCHAR(255), // assessor parcel number; scalar unique key + tags: prop.ARRAY_OF(prop.VARCHAR()), // static native primitive array -> varchar[] column + lot_dimensions: prop.ARRAY_OF(prop.NUMERIC()), // static native numeric array -> numeric[] column + land_use: { + ...prop.ARRAY_OF(prop.ENUM(['RESIDENTIAL', 'COMMERCIAL', 'AGRICULTURAL'])), // updatable native enum array -> varchar[] + membership check on the version table + updatable: true, // a parcel's permitted land uses change over time + }, + }, + unique: ['apn'], +}); const welcomedHomeEvent = new Event({ name: 'welcomed_home_event', properties: { @@ -72,6 +96,7 @@ export const generateSqlSchemasFor = [ user, host, home, + parcel, welcomedHomeEvent, message, ]; diff --git a/src/contract/commands/__snapshots__/generate.acceptance.test.ts.snap b/src/contract/commands/__snapshots__/generate.acceptance.test.ts.snap new file mode 100644 index 0000000..8cd8611 --- /dev/null +++ b/src/contract/commands/__snapshots__/generate.acceptance.test.ts.snap @@ -0,0 +1,228 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generate command should be able to generate schema for valid entities declaration: home table (native primitive array) 1`] = ` +"CREATE TABLE home ( + id bigserial NOT NULL, + uuid uuid NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + name varchar(255) NOT NULL, + built timestamp with time zone NOT NULL, + bedrooms int NOT NULL, + bathrooms int NOT NULL, + tags varchar[] NOT NULL, + host_ids_hash bytea NOT NULL, + CONSTRAINT home_pk PRIMARY KEY (id), + CONSTRAINT home_ux1 UNIQUE (name, host_ids_hash) +); +" +`; + +exports[`generate command should be able to generate schema for valid entities declaration: home upsert function (native array write path) 1`] = ` +"CREATE OR REPLACE FUNCTION upsert_home( + in_name varchar(255), + in_host_ids bigint[], + in_built timestamp with time zone, + in_bedrooms int, + in_bathrooms int, + in_tags varchar[], + in_amenities varchar[], + in_photo_ids bigint[] +) +RETURNS TABLE(id bigint, uuid uuid, created_at timestamp with time zone, effective_at timestamp with time zone, updated_at timestamp with time zone) +LANGUAGE plpgsql +AS $$ + DECLARE + v_static_id bigint; + v_created_at timestamptz := now(); -- define a common created_at timestamp to use + v_matching_version_id bigint; + v_effective_at timestamptz := v_created_at; -- i.e., effective "now" + v_current_version_id_recorded_in_pointer_table bigint; + v_effective_at_of_current_version_recorded_in_pointer_table timestamptz; + v_array_access_index int; + BEGIN + -- find or create the static record + SELECT s.id INTO v_static_id -- try to find the id of the static record + FROM home AS s + WHERE 1=1 + AND (s.name = in_name) + AND (s.host_ids_hash = digest(array_to_string(in_host_ids, ',', '__NULL__'), 'sha256')); + IF (v_static_id IS NULL) THEN -- if static record could not be already found, create the static record + INSERT INTO home AS s + (uuid, created_at, name, host_ids_hash, built, bedrooms, bathrooms, tags) + VALUES + (uuid_generate_v4(), v_created_at, in_name, digest(array_to_string(in_host_ids, ',', '__NULL__'), 'sha256'), in_built, in_bedrooms, in_bathrooms, in_tags) + RETURNING s.id INTO v_static_id; + + -- insert a row into the mapping table for each value in array in_host_ids + FOR v_array_access_index IN 1 .. coalesce(array_upper(in_host_ids, 1), 0) + LOOP + INSERT INTO home_to_host + (created_at, home_id, host_id, array_order_index) + VALUES + (v_created_at, v_static_id, in_host_ids[v_array_access_index], v_array_access_index); + END LOOP; + END IF; + + -- insert new version record to ensure that latest dynamic data is effective, if dynamic data has changed + SELECT v.id INTO v_matching_version_id -- see if latest version record already has this data + FROM home_version AS v + WHERE 1=1 + AND v.home_id = v_static_id -- for this entity + AND v.effective_at = ( -- and is the version record effective at the time of "v_effective_at" + SELECT MAX(ssv.effective_at) + FROM home_version ssv + WHERE ssv.home_id = v_static_id + AND ssv.effective_at <= v_effective_at + ) + AND (v.amenities IS NOT DISTINCT FROM in_amenities) + AND (v.photo_ids_hash = digest(array_to_string(in_photo_ids, ',', '__NULL__'), 'sha256')); + IF (v_matching_version_id IS NULL) THEN -- if the latest version record does not match, insert a new version record + INSERT INTO home_version AS v + (home_id, created_at, effective_at, amenities, photo_ids_hash) + VALUES + (v_static_id, v_created_at, v_effective_at, in_amenities, digest(array_to_string(in_photo_ids, ',', '__NULL__'), 'sha256')) + RETURNING v.id INTO v_matching_version_id; + + -- insert a row into the mapping table for each value in array in_photo_ids + FOR v_array_access_index IN 1 .. coalesce(array_upper(in_photo_ids, 1), 0) + LOOP + INSERT INTO home_version_to_photo + (created_at, home_version_id, photo_id, array_order_index) + VALUES + (v_created_at, v_matching_version_id, in_photo_ids[v_array_access_index], v_array_access_index); + END LOOP; + END IF; + + -- update the current version pointer table, if it is not already up to date + SELECT home_version_id INTO v_current_version_id_recorded_in_pointer_table -- get the version recorded as current for the entity, if any + FROM home_cvp + WHERE 1=1 + AND home_id = v_static_id; -- for this entity + IF (v_current_version_id_recorded_in_pointer_table IS null) THEN -- if its null, then just insert it, since it isn't already defined + INSERT INTO home_cvp + (updated_at, home_id, home_version_id) + VALUES + (v_created_at, v_static_id, v_matching_version_id); + v_current_version_id_recorded_in_pointer_table := v_matching_version_id; -- and record that the current version recorded is now the real current version + END IF; + IF (v_current_version_id_recorded_in_pointer_table <> v_matching_version_id) THEN -- if they are not exactly equal, try to update the current version recorded in the pointer table + SELECT v.effective_at INTO v_effective_at_of_current_version_recorded_in_pointer_table -- grab the effective_at value of the recorded current version + FROM home_version AS v + WHERE v.id = v_current_version_id_recorded_in_pointer_table; + IF (v_effective_at_of_current_version_recorded_in_pointer_table < v_effective_at) THEN -- update cached current version only if the version we just inserted is "newer" than the currently cached version + UPDATE home_cvp + SET + home_version_id = v_matching_version_id, + updated_at = v_created_at + WHERE + home_id = v_static_id; + END IF; + END IF; + + -- return the db generated values + RETURN QUERY + SELECT s.id, s.uuid, s.created_at, v.effective_at AS effective_at, v.created_at AS updated_at + FROM home s + JOIN home_version v ON v.id = v_matching_version_id + WHERE s.id = v_static_id; + END; +$$ +" +`; + +exports[`generate command should be able to generate schema for valid entities declaration: home version table (native enum array + membership check) 1`] = ` +"CREATE TABLE home_version ( + id bigserial NOT NULL, + home_id bigint NOT NULL, + effective_at timestamp with time zone NOT NULL DEFAULT now(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + amenities varchar[] NOT NULL, + photo_ids_hash bytea NOT NULL, + CONSTRAINT home_version_pk PRIMARY KEY (id), + CONSTRAINT home_version_ux1 UNIQUE (home_id, effective_at, created_at), + CONSTRAINT home_version_fk0 FOREIGN KEY (home_id) REFERENCES home (id), + CONSTRAINT home_version_amenities_check CHECK (amenities <@ ARRAY['POOL', 'WIFI', 'PARKING']::varchar[]) +); +CREATE INDEX home_version_fk0_ix ON home_version USING btree (home_id); +" +`; + +exports[`generate command should be able to generate schema for valid entities declaration: home view (native arrays selected through) 1`] = ` +"CREATE OR REPLACE VIEW view_home_current AS + SELECT + s.id, + s.uuid, + s.name, + ( + SELECT coalesce(array_agg(home_to_host.host_id ORDER BY home_to_host.array_order_index), array[]::bigint[]) as array_agg + FROM home_to_host WHERE home_to_host.home_id = s.id + ) as host_ids, + s.built, + s.bedrooms, + s.bathrooms, + coalesce(s.tags, array[]::varchar[]) as tags, + coalesce(v.amenities, array[]::varchar[]) as amenities, + ( + SELECT coalesce(array_agg(home_version_to_photo.photo_id ORDER BY home_version_to_photo.array_order_index), array[]::bigint[]) as array_agg + FROM home_version_to_photo WHERE home_version_to_photo.home_version_id = v.id + ) as photo_ids, + s.created_at, + v.effective_at, + v.created_at as updated_at + FROM home s + JOIN home_cvp cvp ON s.id = cvp.home_id + JOIN home_version v ON v.id = cvp.home_version_id; +" +`; + +exports[`generate command should display help for the generate command: generate --help output 1`] = ` +"Usage: sql-schema-generator generate [options] + +generate sql schema for immutable and mutable entities: tables, upsert method, +and views + +Options: + -c, --config path to config file (default: "codegen.sql.schema.yml") + -h, --help display help for command +" +`; + +exports[`generate command should reject an invalid entities declaration with a helpful error: generate invalid declaration error 1`] = ` +"UserInputError: User input error. prop.ARRAY_OF does not support the serial pseudo-type 'bigserial' as an array element. + +For potential solutions, consider the following: +- serial types are not real postgres types; they are shorthand for an integer column with a sequence default, and postgres has no serial array type. +- use a concrete integer element instead (e.g. prop.BIGINT()), or a reference/uuid element for element-level identity." +`; + +exports[`parcel native-array full round-trip (literal CLI output applied to postgres) should generate a native-array schema whose literal output applies and round-trips through upsert + view against real postgres: parcel round-trip view instance (native arrays through literal CLI output) 1`] = ` +{ + "apn": "__PARCEL_ROUNDTRIP__", + "land_use": [ + "RESIDENTIAL", + ], + "lot_dimensions": [ + 50.5, + 120.25, + ], + "tags": [ + "residential", + "corner-lot", + ], +} +`; + +exports[`parcel native-array full round-trip (literal CLI output applied to postgres) should generate a native-array schema whose literal output applies and round-trips through upsert + view against real postgres: parcel version rows across an enum array change (literal CLI output) 1`] = ` +[ + { + "land_use": [ + "RESIDENTIAL", + ], + }, + { + "land_use": [ + "COMMERCIAL", + ], + }, +] +`; diff --git a/src/contract/commands/generate.acceptance.test.ts b/src/contract/commands/generate.acceptance.test.ts index 80ad88b..aea698b 100644 --- a/src/contract/commands/generate.acceptance.test.ts +++ b/src/contract/commands/generate.acceptance.test.ts @@ -1,5 +1,12 @@ +import { pg as prepare } from 'yesql'; + import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { + type DatabaseConnection, + getDatabaseConnection, +} from '../../.test.utils/databaseConnection'; describe('generate command', () => { it('should be able to generate schema for valid entities declaration', async () => { @@ -15,5 +22,281 @@ describe('generate command', () => { cwd: rootDir, encoding: 'utf-8', }); + + // assert the native-array feature at the acceptance boundary: the CLI-produced artifacts + // must carry a native array column (not a join table) for the primitive/enum arrays on `home`. + // the `home` fixture declares a native primitive array `tags` (static) and a native enum + // array `amenities` (updatable), alongside join-table reference arrays (host_ids/photo_ids). + const generatedDir = path.join(__dirname, '../.test.assets/generated'); + const readGenerated = (relPath: string) => + readFileSync(path.join(generatedDir, relPath), 'utf-8'); + + // the static native primitive array is a real `varchar[]` column on the base table + const homeTableSql = readGenerated('tables/home.sql'); + expect(homeTableSql).toContain('tags varchar[] NOT NULL'); + expect(homeTableSql).toMatchSnapshot('home table (native primitive array)'); + + // the updatable native enum array is a `varchar[]` column with an element-membership check + const homeVersionTableSql = readGenerated('tables/home_version.sql'); + expect(homeVersionTableSql).toContain('amenities varchar[] NOT NULL'); + expect(homeVersionTableSql).toContain( + "CHECK (amenities <@ ARRAY['POOL', 'WIFI', 'PARKING']::varchar[])", + ); + expect(homeVersionTableSql).toMatchSnapshot( + 'home version table (native enum array + membership check)', + ); + + // the upsert function is the native-array WRITE path: primitive/enum arrays are passed as + // native array inputs (`in_tags varchar[]`, `in_amenities varchar[]`) and written in one shot, + // while reference arrays (host_ids/photo_ids) keep the hash + mapping-table path. change + // detection on the version uses the null-safe `IS NOT DISTINCT FROM` for the native enum array. + const homeUpsertSql = readGenerated('functions/upsert_home.sql'); + expect(homeUpsertSql).toContain('in_tags varchar[]'); + expect(homeUpsertSql).toContain('in_amenities varchar[]'); + expect(homeUpsertSql).toContain( + 'v.amenities IS NOT DISTINCT FROM in_amenities', + ); + expect(homeUpsertSql).toMatchSnapshot( + 'home upsert function (native array write path)', + ); + + // the hydrated view selects each native array straight through with the null->[] coalesce + const homeViewSql = readGenerated('views/view_home_current.sql'); + expect(homeViewSql).toContain( + 'coalesce(s.tags, array[]::varchar[]) as tags', + ); + expect(homeViewSql).toContain( + 'coalesce(v.amenities, array[]::varchar[]) as amenities', + ); + expect(homeViewSql).toMatchSnapshot( + 'home view (native arrays selected through)', + ); + }); + + it('should display help for the generate command', async () => { + const rootDir = path.join(__dirname, '../../..'); + + // the --help path is a user-faced contract: it must advertise the command, + // its purpose, and the -c/--config input a human needs to invoke it. + const helpOutput = execSync('./bin/run generate --help', { + cwd: rootDir, + encoding: 'utf-8', + }); + + expect(helpOutput).toContain('sql-schema-generator generate [options]'); + expect(helpOutput).toContain('-c, --config '); + expect(helpOutput).toMatchSnapshot('generate --help output'); + }); + + it('should reject an invalid entities declaration with a helpful error', async () => { + const configPath = path.join( + __dirname, + '../.test.assets/codegen.sql.schema.acceptance.invalid.yml', + ); + const rootDir = path.join(__dirname, '../../..'); + + // the invalid fixture declares a native array of a serial pseudo-type, which + // sql-schema-generator rejects at declare time. exercise the CLI's negative path: + // the command must fail (non-zero exit) with a UserInputError that names the fix. + const negativePath = () => + execSync(`./bin/run generate -c ${configPath}`, { + cwd: rootDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + const caught = (() => { + try { + negativePath(); + return null; + } catch (error) { + return error as { status: number; stderr: string }; + } + })(); + + // the CLI must have failed loud (non-zero exit), not silently produced schema + expect(caught).not.toBeNull(); + expect(caught!.status).not.toEqual(0); + + // snapshot only the stable UserInputError message + fix guidance, minus the + // volatile absolute-path stack frames so the snapshot stays deterministic + const stableError = caught!.stderr.split('\n at ')[0]!.trim(); + expect(stableError).toContain( + "prop.ARRAY_OF does not support the serial pseudo-type 'bigserial'", + ); + expect(stableError).toMatchSnapshot('generate invalid declaration error'); + }); +}); + +describe('parcel native-array full round-trip (literal CLI output applied to postgres)', () => { + // a self-contained native-array entity whose entire generated graph applies cleanly to a real + // postgres — proof that the CLI's on-disk upsert function + hydrated view actually execute (not + // just that their SQL text looks right). home's own graph cannot serve as the vehicle here: it + // transitively emits a `user` table, a reserved word the generator writes unquoted, which + // postgres rejects — so a self-contained entity exercises the identical native-array code paths. + let dbConnection: DatabaseConnection; + + const rootDir = path.join(__dirname, '../../..'); + const generatedDir = path.join(__dirname, '../.test.assets/generated'); + const readGenerated = (relPath: string) => + readFileSync(path.join(generatedDir, relPath), 'utf-8'); + + // db-generated + order-dependent keys are volatile across runs; strip them so the domain array + // columns can be snapshotted. arrays are coerced to plain JSON for a legible diff. + const asStableRow = (row: Record) => { + const stable: Record = {}; + for (const [key, value] of Object.entries(row)) { + if ( + ['id', 'uuid', 'created_at', 'effective_at', 'updated_at'].includes( + key, + ) || + key.endsWith('_id') + ) + continue; + stable[key] = Array.isArray(value) + ? value.map((element) => + element instanceof Date ? element.toISOString() : element, + ) + : value; + } + return stable; + }; + + const upsertParcel = async (input: { + apn: string; + tags: string[]; + lot_dimensions: number[]; + land_use: string[]; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_parcel( + :apn, + :tags, + :lot_dimensions, + :land_use + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + const getParcelFromView = async ({ id }: { id: number }) => { + const result = await dbConnection.query( + prepare('SELECT * FROM view_parcel_current WHERE id = :id')({ id }), + ); + expect(result.rows.length).toEqual(1); + return result.rows[0]; + }; + + const getParcelVersions = async ({ id }: { id: number }) => { + const result = await dbConnection.query( + prepare( + 'SELECT * FROM parcel_version WHERE parcel_id = :id ORDER BY created_at ASC', + )({ id }), + ); + return result.rows; + }; + + const applyGeneratedParcelGraph = async () => { + // reset any prior parcel objects so the apply starts from a clean slate + await dbConnection.query({ + sql: 'DROP VIEW IF EXISTS view_parcel_current', + }); + await dbConnection.query({ sql: 'DROP FUNCTION IF EXISTS upsert_parcel' }); + await dbConnection.query({ sql: 'DROP TABLE IF EXISTS parcel_cvp' }); + await dbConnection.query({ sql: 'DROP TABLE IF EXISTS parcel_version' }); + await dbConnection.query({ sql: 'DROP TABLE IF EXISTS parcel' }); + + // apply the LITERAL generated files, in dependency order + await dbConnection.query({ sql: readGenerated('tables/parcel.sql') }); + await dbConnection.query({ + sql: readGenerated('tables/parcel_version.sql'), + }); + await dbConnection.query({ sql: readGenerated('tables/parcel_cvp.sql') }); + await dbConnection.query({ + sql: readGenerated('functions/upsert_parcel.sql'), + }); + await dbConnection.query({ + sql: readGenerated('views/view_parcel_current.sql'), + }); + }; + + beforeAll(async () => { + dbConnection = await getDatabaseConnection(); + }); + + afterAll(async () => { + await dbConnection.end(); + }); + + // the ACTION under test is the public contract — the `generate` CLI command. the apply + + // exercise + read-back that follow are the VERIFY phase, which rule.require.acceptance.blackbox + // permits to use internals ("verify (then) — internal access allowed"). this proves the + // CLI-produced upsert function + hydrated view actually execute against a real postgres, not + // just that their SQL text reads right. + it('should generate a native-array schema whose literal output applies and round-trips through upsert + view against real postgres', async () => { + // action: invoke the public contract — the `generate` CLI command + execSync( + `./bin/run generate -c ${path.join( + __dirname, + '../.test.assets/codegen.sql.schema.acceptance.yml', + )}`, + { cwd: rootDir, encoding: 'utf-8' }, + ); + + // verify: apply the LITERAL produced artifact to a real postgres + await applyGeneratedParcelGraph(); + + // verify: write native arrays, read them back through the hydrated view + const id = await upsertParcel({ + apn: '__PARCEL_ROUNDTRIP__', + tags: ['residential', 'corner-lot'], + lot_dimensions: [50.5, 120.25], + land_use: ['RESIDENTIAL'], + }); + const view = await getParcelFromView({ id }); + + // the static native primitive + numeric arrays read straight through + expect(view.tags).toEqual(['residential', 'corner-lot']); + expect(view.lot_dimensions.map(Number)).toEqual([50.5, 120.25]); + // the updatable native enum array reads straight through from the version row + expect(view.land_use).toEqual(['RESIDENTIAL']); + + // snapshot the full round-tripped instance so a reviewer sees exactly what the CLI-produced + // view returns for every native array type in a PR diff + expect(asStableRow(view)).toMatchSnapshot( + 'parcel round-trip view instance (native arrays through literal CLI output)', + ); + + // verify: an identical re-write is a no-op — the null-safe IS NOT DISTINCT FROM matches, + // so no new version is cut + const idempotentInput = { + apn: '__PARCEL_IDEMPOTENT__', + tags: ['flat'], + lot_dimensions: [100], + land_use: ['AGRICULTURAL'], + }; + const idempotentId = await upsertParcel(idempotentInput); + expect((await getParcelVersions({ id: idempotentId })).length).toEqual(1); + await upsertParcel(idempotentInput); + expect((await getParcelVersions({ id: idempotentId })).length).toEqual(1); + + // verify: a changed enum array cuts exactly one new version + const changeBase = { + apn: '__PARCEL_CHANGE__', + tags: ['hillside'], + lot_dimensions: [75.5], + land_use: ['RESIDENTIAL'], + }; + const changeId = await upsertParcel(changeBase); + expect((await getParcelVersions({ id: changeId })).length).toEqual(1); + await upsertParcel({ ...changeBase, land_use: ['COMMERCIAL'] }); + const changeVersions = await getParcelVersions({ id: changeId }); + expect(changeVersions.length).toEqual(2); + + // snapshot the before -> after version rows so the CLI-produced change detection is visible + expect(changeVersions.map(asStableRow)).toMatchSnapshot( + 'parcel version rows across an enum array change (literal CLI output)', + ); }); }); diff --git a/src/domain.operations/define/__snapshots__/defineProperty.test.ts.snap b/src/domain.operations/define/__snapshots__/defineProperty.test.ts.snap new file mode 100644 index 0000000..2d8d880 --- /dev/null +++ b/src/domain.operations/define/__snapshots__/defineProperty.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generateProperty ARRAY_OF should throw at declare time for a custom (non-enum) check element 1`] = ` +"User input error. a native array column only supports the ENUM check shape; a custom or scalar check cannot be applied to a native array column. + +For potential solutions, consider the following: +- a scalar check (e.g. a regex or inequality) is operator-invalid against an array column and would fail only at apply time. +- drop the custom check, or express the constraint via prop.ENUM([...]) for element-membership. +" +`; + +exports[`generateProperty ARRAY_OF should throw at declare time for a serial pseudo-type element 1`] = ` +"User input error. prop.ARRAY_OF does not support the serial pseudo-type 'bigserial' as an array element. + +For potential solutions, consider the following: +- serial types are not real postgres types; they are shorthand for an integer column with a sequence default, and postgres has no serial array type. +- use a concrete integer element instead (e.g. prop.BIGINT()), or a reference/uuid element for element-level identity. +" +`; + +exports[`generateProperty ARRAY_OF should throw at declare time for an already-arrayed (nested) element 1`] = ` +"User input error. prop.ARRAY_OF cannot be applied to an already-arrayed property (nested arrays are not supported). + +For potential solutions, consider the following: +- apply prop.ARRAY_OF exactly once, to a scalar element (e.g. prop.ARRAY_OF(prop.VARCHAR())). +" +`; diff --git a/src/domain.operations/define/defineProperty.test.ts b/src/domain.operations/define/defineProperty.test.ts index a4a49aa..e6d378e 100644 --- a/src/domain.operations/define/defineProperty.test.ts +++ b/src/domain.operations/define/defineProperty.test.ts @@ -28,6 +28,127 @@ describe('generateProperty', () => { precision: 255, }); }); + describe('ARRAY_OF', () => { + it('should accept a primitive VARCHAR element and set the array flag', () => { + const property = prop.ARRAY_OF(prop.VARCHAR()); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.VARCHAR }); + expect(property.references).toEqual(undefined); + }); + it('should accept a primitive NUMERIC element and set the array flag', () => { + const property = prop.ARRAY_OF(prop.NUMERIC()); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.NUMERIC }); + }); + it('should accept a primitive BOOLEAN element and set the array flag', () => { + const property = prop.ARRAY_OF(prop.BOOLEAN()); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.BOOLEAN }); + }); + it('should accept a primitive TIMESTAMPTZ element and set the array flag', () => { + const property = prop.ARRAY_OF(prop.TIMESTAMPTZ()); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.TIMESTAMPTZ }); + }); + it('should recast the enum scalar IN check into an element-membership check for the array', () => { + const property = prop.ARRAY_OF( + prop.ENUM(['ACTIVE', 'FAULTED', 'OFFLINE']), + ); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.VARCHAR }); + expect(property.check).toEqual( + "($COLUMN_NAME <@ ARRAY['ACTIVE', 'FAULTED', 'OFFLINE']::varchar[])", + ); + }); + it('should keep a REFERENCES element on the join-table path, unchanged', () => { + const zone = new Entity({ + name: 'zone', + properties: { name: prop.VARCHAR() }, + unique: ['name'], + }); + const property = prop.ARRAY_OF(prop.REFERENCES(zone)); + expect(property.array).toEqual(true); + expect(property.references).toEqual('zone'); + }); + it('should keep a UUID element on the join-table path, unchanged', () => { + const property = prop.ARRAY_OF(prop.UUID()); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ name: DataTypeName.UUID }); + expect(property.check).toEqual(undefined); + }); + it('should throw at declare time for a serial pseudo-type element', () => { + try { + prop.ARRAY_OF(prop.BIGSERIAL()); + throw new Error('should not reach here'); + } catch (error) { + expect(error.message).toContain( + "does not support the serial pseudo-type 'bigserial'", + ); + expect(error.message).toContain('prop.BIGINT()'); + expect(error.message).toMatchSnapshot(); // the exact declare-time error a developer sees + } + }); + // data-driven proof that ARRAY_OF accepts every documented primitive element type, + // not just the five the round-trip integration test exercises + const PRIMITIVE_ELEMENT_CASES = [ + { description: 'SMALLINT', element: prop.SMALLINT() }, + { description: 'INT', element: prop.INT() }, + { description: 'BIGINT', element: prop.BIGINT() }, + { description: 'NUMERIC', element: prop.NUMERIC() }, + { description: 'REAL', element: prop.REAL() }, + { description: 'DOUBLE_PRECISION', element: prop.DOUBLE_PRECISION() }, + { description: 'CHAR', element: prop.CHAR(3) }, + { description: 'VARCHAR', element: prop.VARCHAR() }, + { description: 'TEXT', element: prop.TEXT() }, + { description: 'BYTEA', element: prop.BYTEA() }, + { description: 'TIMESTAMP', element: prop.TIMESTAMP() }, + { description: 'TIMESTAMPTZ', element: prop.TIMESTAMPTZ() }, + { description: 'TIME', element: prop.TIME() }, + { description: 'DATE', element: prop.DATE() }, + { description: 'BOOLEAN', element: prop.BOOLEAN() }, + ]; + PRIMITIVE_ELEMENT_CASES.map((thisCase) => + it(`should accept a ${thisCase.description} element and set the array flag`, () => { + const property = prop.ARRAY_OF(thisCase.element); + expect(property.array).toEqual(true); + expect(property.references).toEqual(undefined); + expect(property.type.name).toEqual(thisCase.element.type.name); + }), + ); + it('should preserve the precision + scale of a NUMERIC element when arrayed', () => { + const property = prop.ARRAY_OF(prop.NUMERIC(10, 2)); + expect(property.array).toEqual(true); + expect(property.type).toMatchObject({ + name: DataTypeName.NUMERIC, + precision: 10, + scale: 2, + }); + }); + it('should throw at declare time for an already-arrayed (nested) element', () => { + try { + prop.ARRAY_OF(prop.ARRAY_OF(prop.VARCHAR())); + throw new Error('should not reach here'); + } catch (error) { + expect(error.message).toContain( + 'cannot be applied to an already-arrayed property', + ); + expect(error.message).toMatchSnapshot(); + } + }); + it('should throw at declare time for a custom (non-enum) check element', () => { + const customChecked = new Property({ + ...prop.VARCHAR(), + check: "($COLUMN_NAME ~ '^SN')", + }); + try { + prop.ARRAY_OF(customChecked); + throw new Error('should not reach here'); + } catch (error) { + expect(error.message).toContain('only supports the ENUM check shape'); + expect(error.message).toMatchSnapshot(); // the exact declare-time error a developer sees + } + }); + }); it('should throw an error if entity REFERENCES_VERSION of a non-updatable entity', () => { const apple = new Literal({ name: 'apple', diff --git a/src/domain.operations/define/defineProperty.ts b/src/domain.operations/define/defineProperty.ts index 5ae6f7f..6a33d99 100644 --- a/src/domain.operations/define/defineProperty.ts +++ b/src/domain.operations/define/defineProperty.ts @@ -1,7 +1,6 @@ /* purpose: provide convenient tools to define types */ -import { serialize } from 'domain-objects'; import { isAFunction } from 'type-fns'; import { @@ -10,6 +9,8 @@ import { type Entity, Property, } from '@src/domain.objects'; +import { castCheckToArrayElementMembership } from '@src/domain.operations/utils/castCheckToArrayElementMembership'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; import { UserInputError } from '@src/utils/errors/UserInputError'; /** @@ -358,16 +359,78 @@ export const REFERENCES_VERSION = ( }; /** - * ARRAY_OF is an alias which sets the array flag to true on a property. + * the serial pseudo-types, which are not valid array element types in postgres. * - * This flag tells the generator to create a mapping table and to expect to write and read an array of these values. It results in the addition of a BINARY(32) column to the base table on which uniqueness can be defined. + * serial/smallserial/bigserial are not real types; they are shorthand for an integer column + * with an attached sequence default. postgres has no `serial[]` array type, so an array of a + * serial element cannot produce valid DDL. we reject it at declare time (fail-fast) rather + * than emit invalid DDL that only fails at `sql-schema-control apply` time. + */ +const serialTypeNames = [ + DataTypeName.SMALLSERIAL, + DataTypeName.SERIAL, + DataTypeName.BIGSERIAL, +]; + +/** + * ARRAY_OF is an alias which sets the array flag to true on a property. * - * NOTE: only arrays of REFERENCEs or UUIDs are supported. + * The storage model depends on the element kind: + * - REFERENCEs and UUIDs are stored via a join table, since they represent element-level + * references (postgres cannot foreign-key an array element). This results in the addition + * of a BINARY(32) hash column to the base table on which uniqueness can be defined. + * - Primitives and ENUMs are stored as a native postgres array column (e.g. `text[]`, + * `numeric[]`, `[]`) directly on the base (and version) table. This suits small, + * read-mostly lists. The supported primitive element types are: SMALLINT, INT, BIGINT, + * NUMERIC, REAL, DOUBLE_PRECISION, CHAR, VARCHAR, TEXT, BYTEA, TIMESTAMP, TIMESTAMPTZ, + * TIME, DATE, and BOOLEAN. + * + * NOT supported as array elements: the serial pseudo-types (SMALLSERIAL, SERIAL, BIGSERIAL), + * which are rejected at declare time since postgres has no serial array type. + * + * NOTE: reserve native arrays for small, read-mostly lists; element-level mutation rewrites + * the whole cell, and native arrays cannot enforce element-level foreign keys or uniqueness. */ export const ARRAY_OF = (property: Property) => { - const isArrayOfReferences = !!property.references; - const isArrayOfUuids = serialize(property) === serialize(UUID()); - if (!isArrayOfReferences && !isArrayOfUuids) - throw new Error('only arrays of REFERENCEs or UUIDs are supported'); - return new Property({ ...property, array: true }); + // guard: reject a nested array (postgres has no multi-dimensional support here). without this, + // ARRAY_OF(ARRAY_OF(x)) would silently no-op the second wrap into a single-dimension array. + if (property.array) + throw new UserInputError({ + reason: + 'prop.ARRAY_OF cannot be applied to an already-arrayed property (nested arrays are not supported)', + potentialSolution: [ + '', + '- apply prop.ARRAY_OF exactly once, to a scalar element (e.g. prop.ARRAY_OF(prop.VARCHAR())).', + ].join('\n'), + }); + + // flip the array flag; the arrayed candidate then decides its own storage model + const arrayProperty = new Property({ ...property, array: true }); + + // a reference or uuid element stays on the join-table path, unchanged + // - delegate to isNativeArrayProperty so there is one classifier, not two + if (!isNativeArrayProperty({ property: arrayProperty })) return arrayProperty; + + // guard: reject the serial pseudo-types, which have no valid postgres array form + if (serialTypeNames.includes(property.type.name)) + throw new UserInputError({ + reason: `prop.ARRAY_OF does not support the serial pseudo-type '${property.type.name}' as an array element`, + potentialSolution: [ + '', + '- serial types are not real postgres types; they are shorthand for an integer column with a sequence default, and postgres has no serial array type.', + '- use a concrete integer element instead (e.g. prop.BIGINT()), or a reference/uuid element for element-level identity.', + ].join('\n'), + }); + + // otherwise it is a primitive or enum element -> native array column + // - an enum carries a scalar `IN (...)` check; recast it to element-membership for the array. + // this is early feedback for the baked-in call order; the authoritative guard runs again + // at DDL emission (generateTable) so the post-hoc `{ ...ARRAY_OF(x), check }` order is + // caught too. the recast is idempotent, so running it in both places is safe. + return new Property({ + ...arrayProperty, + check: property.check + ? castCheckToArrayElementMembership({ check: property.check }) + : property.check, + }); }; diff --git a/src/domain.operations/generate/__snapshots__/nativeArrayColumns.integration.test.ts.snap b/src/domain.operations/generate/__snapshots__/nativeArrayColumns.integration.test.ts.snap new file mode 100644 index 0000000..18846f2 --- /dev/null +++ b/src/domain.operations/generate/__snapshots__/nativeArrayColumns.integration.test.ts.snap @@ -0,0 +1,429 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`native array columns broad primitive element types should round-trip an explicit empty array as an empty array: roundtrip view instance (broad primitive types, all empty) 1`] = ` +{ + "bigs": [], + "blobs": [], + "codes": [], + "days": [], + "doubles": [], + "ints": [], + "moments": [], + "notes": [], + "prices": [], + "reals": [], + "serial_number": "__PROBE_EMPTY__", + "shorts": [], + "times": [], +} +`; + +exports[`native array columns broad primitive element types should round-trip the broad primitive array types through the view: roundtrip view instance (broad primitive element types) 1`] = ` +{ + "bigs": [ + "1000", + "2000", + ], + "blobs": [ + "0102", + "0304", + ], + "codes": [ + "abc", + "xyz", + ], + "days": [ + "2026-01-01T00:00:00.000Z", + "2026-02-01T00:00:00.000Z", + ], + "doubles": [ + 3.25, + 4.75, + ], + "ints": [ + 10, + 20, + ], + "moments": [ + "2026-01-01T00:00:00.000Z", + "2026-02-01T00:00:00.000Z", + ], + "notes": [ + "hello", + "world", + ], + "prices": [ + 10.25, + 20.5, + ], + "reals": [ + 1.5, + 2.5, + ], + "serial_number": "__PROBE_ROUNDTRIP__", + "shorts": [ + 1, + 2, + ], + "times": [ + "14:30:00", + "09:00:00", + ], +} +`; + +exports[`native array columns entity mixing native and join-table arrays should round-trip both arrays through upsert and the view without collision: roundtrip view instance (native array + join-table array) 1`] = ` +{ + "labels": [ + "alpha", + "beta", + ], + "part_uuids_count": 2, +} +`; + +exports[`native array columns nullable updatable native array (CVP null<->value transition) should bump the version across value<->null transitions, but not on a no-op: version rows across value<->null transitions (nullable native array) 1`] = ` +[ + { + "maybe_tags": [ + "a", + "b", + ], + }, + { + "maybe_tags": null, + }, + { + "maybe_tags": [ + "a", + "b", + ], + }, +] +`; + +exports[`native array columns nullable updatable native array (CVP null<->value transition) should not bump the version when an unchanged array holds a NULL element: version row holding a NULL array element (unchanged, no bump) 1`] = ` +[ + { + "maybe_tags": [ + "a", + null, + ], + }, +] +`; + +exports[`native array columns should accept an explicit empty enum array through the element-membership check: roundtrip view instance (all empty arrays) 1`] = ` +{ + "flags": [], + "labels": [], + "moments_at": [], + "readings": [], + "serial_number": "__SERIAL_EMPTY_ENUM__", + "statuses": [], + "tags": [], +} +`; + +exports[`native array columns should bump the version exactly once for a change in each updatable array element type: version rows across a change in each updatable native array element type 1`] = ` +[ + { + "flags": [ + false, + ], + "moments_at": [ + "2026-04-01T00:00:00.000Z", + ], + "readings": [ + 5, + ], + "statuses": [ + "ACTIVE", + ], + "tags": [ + "before", + ], + }, + { + "flags": [ + false, + ], + "moments_at": [ + "2026-04-01T00:00:00.000Z", + ], + "readings": [ + 5, + ], + "statuses": [ + "ACTIVE", + ], + "tags": [ + "after", + ], + }, + { + "flags": [ + false, + ], + "moments_at": [ + "2026-04-01T00:00:00.000Z", + ], + "readings": [ + 6, + ], + "statuses": [ + "ACTIVE", + ], + "tags": [ + "after", + ], + }, + { + "flags": [ + true, + ], + "moments_at": [ + "2026-04-01T00:00:00.000Z", + ], + "readings": [ + 6, + ], + "statuses": [ + "ACTIVE", + ], + "tags": [ + "after", + ], + }, + { + "flags": [ + true, + ], + "moments_at": [ + "2026-05-01T00:00:00.000Z", + ], + "readings": [ + 6, + ], + "statuses": [ + "ACTIVE", + ], + "tags": [ + "after", + ], + }, + { + "flags": [ + true, + ], + "moments_at": [ + "2026-05-01T00:00:00.000Z", + ], + "readings": [ + 6, + ], + "statuses": [ + "FAULTED", + ], + "tags": [ + "after", + ], + }, +] +`; + +exports[`native array columns should reject an enum array element outside the allowed set 1`] = `"new row for relation "sensor_version" violates check constraint "sensor_version_statuses_check""`; + +exports[`native array columns should reject an enum array that holds a NULL element: enum array NULL-element rejection error 1`] = `"new row for relation "sensor_version" violates check constraint "sensor_version_statuses_check""`; + +exports[`native array columns should round-trip native arrays through upsert and the hydrated view: roundtrip view instance (all native array element types) 1`] = ` +{ + "flags": [ + true, + false, + ], + "labels": [ + "alpha", + "beta", + ], + "moments_at": [ + "2026-01-01T00:00:00.000Z", + "2026-02-01T00:00:00.000Z", + ], + "readings": [ + 1, + 2, + ], + "serial_number": "__SERIAL_ROUNDTRIP__", + "statuses": [ + "ACTIVE", + "FAULTED", + ], + "tags": [ + "a", + "b", + ], +} +`; + +exports[`native array columns should snapshot the generated DDL, upsert function, and view for eyeball review 1`] = ` +"CREATE TABLE sensor ( + id bigserial NOT NULL, + uuid uuid NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + serial_number varchar NOT NULL, + labels varchar[] NOT NULL, + CONSTRAINT sensor_pk PRIMARY KEY (id), + CONSTRAINT sensor_ux1 UNIQUE (serial_number) +);" +`; + +exports[`native array columns should snapshot the generated DDL, upsert function, and view for eyeball review 2`] = ` +"CREATE TABLE sensor_version ( + id bigserial NOT NULL, + sensor_id bigint NOT NULL, + effective_at timestamp with time zone NOT NULL DEFAULT now(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + tags varchar[] NOT NULL, + readings numeric[] NOT NULL, + flags boolean[] NOT NULL, + moments_at timestamp with time zone[] NOT NULL, + statuses varchar[] NOT NULL, + CONSTRAINT sensor_version_pk PRIMARY KEY (id), + CONSTRAINT sensor_version_ux1 UNIQUE (sensor_id, effective_at, created_at), + CONSTRAINT sensor_version_fk0 FOREIGN KEY (sensor_id) REFERENCES sensor (id), + CONSTRAINT sensor_version_statuses_check CHECK (statuses <@ ARRAY['ACTIVE', 'FAULTED', 'OFFLINE']::varchar[]) +); +CREATE INDEX sensor_version_fk0_ix ON sensor_version USING btree (sensor_id);" +`; + +exports[`native array columns should snapshot the generated DDL, upsert function, and view for eyeball review 3`] = ` +"CREATE OR REPLACE FUNCTION upsert_sensor( + in_serial_number varchar, + in_labels varchar[], + in_tags varchar[], + in_readings numeric[], + in_flags boolean[], + in_moments_at timestamp with time zone[], + in_statuses varchar[] +) +RETURNS TABLE(id bigint, uuid uuid, created_at timestamp with time zone, effective_at timestamp with time zone, updated_at timestamp with time zone) +LANGUAGE plpgsql +AS $$ + DECLARE + v_static_id bigint; + v_created_at timestamptz := now(); -- define a common created_at timestamp to use + v_matching_version_id bigint; + v_effective_at timestamptz := v_created_at; -- i.e., effective "now" + v_current_version_id_recorded_in_pointer_table bigint; + v_effective_at_of_current_version_recorded_in_pointer_table timestamptz; + BEGIN + -- find or create the static record + SELECT s.id INTO v_static_id -- try to find the id of the static record + FROM sensor AS s + WHERE 1=1 + AND (s.serial_number = in_serial_number); + IF (v_static_id IS NULL) THEN -- if static record could not be already found, create the static record + INSERT INTO sensor AS s + (uuid, created_at, serial_number, labels) + VALUES + (uuid_generate_v4(), v_created_at, in_serial_number, in_labels) + RETURNING s.id INTO v_static_id; + END IF; + + -- insert new version record to ensure that latest dynamic data is effective, if dynamic data has changed + SELECT v.id INTO v_matching_version_id -- see if latest version record already has this data + FROM sensor_version AS v + WHERE 1=1 + AND v.sensor_id = v_static_id -- for this entity + AND v.effective_at = ( -- and is the version record effective at the time of "v_effective_at" + SELECT MAX(ssv.effective_at) + FROM sensor_version ssv + WHERE ssv.sensor_id = v_static_id + AND ssv.effective_at <= v_effective_at + ) + AND (v.tags IS NOT DISTINCT FROM in_tags) + AND (v.readings IS NOT DISTINCT FROM in_readings) + AND (v.flags IS NOT DISTINCT FROM in_flags) + AND (v.moments_at IS NOT DISTINCT FROM in_moments_at) + AND (v.statuses IS NOT DISTINCT FROM in_statuses); + IF (v_matching_version_id IS NULL) THEN -- if the latest version record does not match, insert a new version record + INSERT INTO sensor_version AS v + (sensor_id, created_at, effective_at, tags, readings, flags, moments_at, statuses) + VALUES + (v_static_id, v_created_at, v_effective_at, in_tags, in_readings, in_flags, in_moments_at, in_statuses) + RETURNING v.id INTO v_matching_version_id; + END IF; + + -- update the current version pointer table, if it is not already up to date + SELECT sensor_version_id INTO v_current_version_id_recorded_in_pointer_table -- get the version recorded as current for the entity, if any + FROM sensor_cvp + WHERE 1=1 + AND sensor_id = v_static_id; -- for this entity + IF (v_current_version_id_recorded_in_pointer_table IS null) THEN -- if its null, then just insert it, since it isn't already defined + INSERT INTO sensor_cvp + (updated_at, sensor_id, sensor_version_id) + VALUES + (v_created_at, v_static_id, v_matching_version_id); + v_current_version_id_recorded_in_pointer_table := v_matching_version_id; -- and record that the current version recorded is now the real current version + END IF; + IF (v_current_version_id_recorded_in_pointer_table <> v_matching_version_id) THEN -- if they are not exactly equal, try to update the current version recorded in the pointer table + SELECT v.effective_at INTO v_effective_at_of_current_version_recorded_in_pointer_table -- grab the effective_at value of the recorded current version + FROM sensor_version AS v + WHERE v.id = v_current_version_id_recorded_in_pointer_table; + IF (v_effective_at_of_current_version_recorded_in_pointer_table < v_effective_at) THEN -- update cached current version only if the version we just inserted is "newer" than the currently cached version + UPDATE sensor_cvp + SET + sensor_version_id = v_matching_version_id, + updated_at = v_created_at + WHERE + sensor_id = v_static_id; + END IF; + END IF; + + -- return the db generated values + RETURN QUERY + SELECT s.id, s.uuid, s.created_at, v.effective_at AS effective_at, v.created_at AS updated_at + FROM sensor s + JOIN sensor_version v ON v.id = v_matching_version_id + WHERE s.id = v_static_id; + END; +$$" +`; + +exports[`native array columns should snapshot the generated DDL, upsert function, and view for eyeball review 4`] = ` +"CREATE OR REPLACE VIEW view_sensor_current AS + SELECT + s.id, + s.uuid, + s.serial_number, + coalesce(s.labels, array[]::varchar[]) as labels, + coalesce(v.tags, array[]::varchar[]) as tags, + coalesce(v.readings, array[]::numeric[]) as readings, + coalesce(v.flags, array[]::boolean[]) as flags, + coalesce(v.moments_at, array[]::timestamp with time zone[]) as moments_at, + coalesce(v.statuses, array[]::varchar[]) as statuses, + s.created_at, + v.effective_at, + v.created_at as updated_at + FROM sensor s + JOIN sensor_cvp cvp ON s.id = cvp.sensor_id + JOIN sensor_version v ON v.id = cvp.sensor_version_id;" +`; + +exports[`native array columns static native array only entity (no version table) should present a NULL native array cell as an empty array via the view: roundtrip view instance (NULL native array cell coalesced to []) 1`] = ` +{ + "labels": [], + "serial_number": "__BEACON_NULL_LABELS__", +} +`; + +exports[`native array columns static native array only entity (no version table) should round-trip the static native array through the view: roundtrip view instance (static native array only, no version table) 1`] = ` +{ + "labels": [ + "alpha", + "beta", + ], + "serial_number": "__BEACON_ROUNDTRIP__", +} +`; diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineDeclarations.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineDeclarations.ts index bedcd2e..d9bf620 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineDeclarations.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineDeclarations.ts @@ -1,4 +1,5 @@ import type { Entity } from '@src/domain.objects'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; export const defineDeclarations = ({ entity }: { entity: Entity }) => { const declarations = []; @@ -22,11 +23,12 @@ export const defineDeclarations = ({ entity }: { entity: Entity }) => { ); } - // add the mapping table loop declarations, if needed - const hasArrayProperties = Object.values(entity.properties).some( - (property) => !!property.array, + // add the join-table loop declarations, if needed + // - only join-table arrays loop per element; native arrays are written as one value + const hasJoinTableArrayProperties = Object.values(entity.properties).some( + (property) => isJoinTableArrayProperty({ property }), ); - if (hasArrayProperties) { + if (hasJoinTableArrayProperties) { declarations.push( 'v_array_access_index int;', // tracks the index of the array that we're at ); diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineFindOrCreateStaticEntityLogic.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineFindOrCreateStaticEntityLogic.ts index 6569c95..25b1220 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineFindOrCreateStaticEntityLogic.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineFindOrCreateStaticEntityLogic.ts @@ -2,6 +2,7 @@ import type { Entity } from '@src/domain.objects'; import { prop } from '@src/domain.operations/define'; import { castPropertyToColumnName } from '@src/domain.operations/generate/utils/castPropertyToColumnName'; import { pickKeysFromObject } from '@src/domain.operations/generate/utils/pickKeysFromObject'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; import { defineMappingTableInsertsForArrayProperty } from './defineMappingTableInsertsForArrayProperty'; import { castPropertyToInputVariableName } from './utils/castPropertyToInputVariableName'; @@ -59,10 +60,12 @@ export const defineFindOrCreateStaticEntityLogic = ({ ); })(); - // define the array properties, for which we'll need to insert into a mapping table + // define the join-table array properties, for which we insert into a join table per element + // - native arrays are written inline as a column value, so they are excluded here const staticArrayProperties = pickKeysFromObject({ object: entity.properties, - keep: (property) => !!property.array && !property.updatable, + keep: (property) => + !property.updatable && isJoinTableArrayProperty({ property }), }); const mappingTableInserts = Object.entries(staticArrayProperties).map( ([name, definition]) => diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineInsertVersionIfDynamicDataChangedLogic.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineInsertVersionIfDynamicDataChangedLogic.ts index beae260..2b7b4b8 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineInsertVersionIfDynamicDataChangedLogic.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/defineInsertVersionIfDynamicDataChangedLogic.ts @@ -1,6 +1,7 @@ import type { Entity } from '@src/domain.objects'; import { castPropertyToColumnName } from '@src/domain.operations/generate/utils/castPropertyToColumnName'; import { pickKeysFromObject } from '@src/domain.operations/generate/utils/pickKeysFromObject'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; import { defineMappingTableInsertsForArrayProperty } from './defineMappingTableInsertsForArrayProperty'; import { castPropertyToTableColumnValueReference } from './utils/castPropertyToTableColumnValueReference'; @@ -41,10 +42,12 @@ export const defineInsertVersionIfDynamicDataChangedLogic = ({ }), ); - // define the array properties, for which we'll need to insert into a mapping table + // define the join-table array properties, for which we insert into a join table per element + // - native arrays are written inline as a column value, so they are excluded here const updatableArrayProperties = pickKeysFromObject({ object: entity.properties, - keep: (property) => !!property.array && !!property.updatable, + keep: (property) => + !!property.updatable && isJoinTableArrayProperty({ property }), }); const mappingTableInserts = Object.entries(updatableArrayProperties).map( ([name, definition]) => diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/__snapshots__/castPropertyToWhereClauseConditional.test.ts.snap b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/__snapshots__/castPropertyToWhereClauseConditional.test.ts.snap index f91a85e..c98acac 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/__snapshots__/castPropertyToWhereClauseConditional.test.ts.snap +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/__snapshots__/castPropertyToWhereClauseConditional.test.ts.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`castPropertyToWhereClauseConditional should compare a native array column by value with a null-safe operator, not by hash 1`] = `"AND (t.tags IS NOT DISTINCT FROM in_tags)"`; + exports[`castPropertyToWhereClauseConditional should define the conditional accurately for a array property 1`] = `"AND (t.participant_ids_hash = digest(array_to_string(in_participant_ids, ',', '__NULL__'), 'sha256'))"`; exports[`castPropertyToWhereClauseConditional should define the conditional accurately for a unit property 1`] = `"AND (t.creator_id = in_creator_id)"`; + +exports[`castPropertyToWhereClauseConditional should use the same null-safe operator for a nullable native array column 1`] = `"AND (t.labels IS NOT DISTINCT FROM in_labels)"`; diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.test.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.test.ts new file mode 100644 index 0000000..340ca65 --- /dev/null +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.test.ts @@ -0,0 +1,48 @@ +import { Literal } from '@src/domain.objects'; +import { prop } from '@src/domain.operations/define'; + +import { castPropertyToTableColumnValueReference } from './castPropertyToTableColumnValueReference'; + +describe('castPropertyToTableColumnValueReference', () => { + const user = new Literal({ + name: 'user', + properties: { name: prop.VARCHAR(255) }, + }); + + it('should reference a scalar property by its input variable directly', () => { + const reference = castPropertyToTableColumnValueReference({ + name: 'name', + definition: prop.VARCHAR(), + }); + expect(reference).toEqual('in_name'); + }); + + it('should reference a native array by its input variable directly (no hash)', () => { + const reference = castPropertyToTableColumnValueReference({ + name: 'tags', + definition: prop.ARRAY_OF(prop.VARCHAR()), + }); + // the native array is written to its real array column, so its own value is used + expect(reference).toEqual('in_tags'); + expect(reference).not.toContain('sha256'); + }); + + it('should reduce a join-table array to a sha256 hash of its input value', () => { + const reference = castPropertyToTableColumnValueReference({ + name: 'participant_ids', + definition: prop.ARRAY_OF(prop.REFERENCES(user)), + }); + // a join-table array is change-detected via a sha256 hash column + expect(reference).toEqual( + "digest(array_to_string(in_participant_ids, ',', '__NULL__'), 'sha256')", + ); + }); + + it('should reduce a uuid array (join-table) to a sha256 hash as well', () => { + const reference = castPropertyToTableColumnValueReference({ + name: 'owner_uuids', + definition: prop.ARRAY_OF(prop.UUID()), + }); + expect(reference).toContain('sha256'); + }); +}); diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.ts index 600e8ab..a294323 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToTableColumnValueReference.ts @@ -1,14 +1,16 @@ import type { Property } from '@src/domain.objects'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; import { castPropertyToInputVariableName } from './castPropertyToInputVariableName'; /* defines the input value reference for the main tables (static and version) - - purpose: abstract away the fact that we have to "hash" the input for array variables + - a native array is written to its real array column directly (its own value, no hash) + - a join-table array is reduced to a sha256 hash column (the column on which change is detected) + - a scalar is written directly - NOTE: this is not used by the mapping tables - as they have a special way of parsing the input variable value - - i.e., they parse and loop through it - - not to mention they can not use the hash value + note: the join-table hash reference is not used by the join tables themselves - they loop + through the input array element by element, and can not use the hash value */ export const castPropertyToTableColumnValueReference = ({ name, @@ -19,7 +21,10 @@ export const castPropertyToTableColumnValueReference = ({ }) => { const inputVariableName = castPropertyToInputVariableName({ name }); - // if it is an array, then hash the input value into a binary value + // a native array is written to a real array column directly - no hash + if (isNativeArrayProperty({ property: definition })) return inputVariableName; + + // a join-table array hashes the input value into a binary value, for change detection if (definition.array) return `digest(array_to_string(${inputVariableName}, ',', '__NULL__'), 'sha256')`; diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.test.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.test.ts index b1be419..f4eb70d 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.test.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.test.ts @@ -13,6 +13,8 @@ describe('castPropertyToWhereClauseConditional', () => { properties: { creator_id: prop.REFERENCES(user), participant_ids: prop.ARRAY_OF(prop.REFERENCES(user)), + tags: prop.ARRAY_OF(prop.VARCHAR()), // native array + labels: { ...prop.ARRAY_OF(prop.VARCHAR()), nullable: true }, // nullable native array }, unique: ['creator_id'], }); @@ -35,4 +37,29 @@ describe('castPropertyToWhereClauseConditional', () => { ); // should convert input array to sha256 hash expect(definition).toMatchSnapshot(); }); + it('should compare a native array column by value with a null-safe operator, not by hash', () => { + const definition = castPropertyToWhereClauseConditional({ + name: 'tags', + definition: plan.properties.tags!, + tableAlias: 't', + }); + // a native array compares the real column against the input value directly, null-safe + // (IS NOT DISTINCT FROM handles a NULL whole-array or a NULL element; plain `=` yields NULL) + expect(definition).toEqual('AND (t.tags IS NOT DISTINCT FROM in_tags)'); + // and never routes through the join-table sha256 hash path + expect(definition).not.toContain('sha256'); + expect(definition).not.toContain('tags_hash'); + expect(definition).toMatchSnapshot(); + }); + it('should use the same null-safe operator for a nullable native array column', () => { + const definition = castPropertyToWhereClauseConditional({ + name: 'labels', + definition: plan.properties.labels!, + tableAlias: 't', + }); + // IS NOT DISTINCT FROM already covers the NULL whole-array case, so no extra OR branch + expect(definition).toEqual('AND (t.labels IS NOT DISTINCT FROM in_labels)'); + expect(definition).not.toContain('sha256'); + expect(definition).toMatchSnapshot(); + }); }); diff --git a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.ts b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.ts index e4e6fbc..57e8d08 100644 --- a/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.ts +++ b/src/domain.operations/generate/entityFunctions/generateEntityUpsert/utils/castPropertyToWhereClauseConditional.ts @@ -1,5 +1,6 @@ import type { Property } from '@src/domain.objects'; import { castPropertyToColumnName } from '@src/domain.operations/generate/utils/castPropertyToColumnName'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; import { castPropertyToTableColumnValueReference } from './castPropertyToTableColumnValueReference'; @@ -18,6 +19,15 @@ export const castPropertyToWhereClauseConditional = ({ name, definition, }); + + // a native array compares with `IS NOT DISTINCT FROM`, which is null-safe on both the whole + // array and its elements. plain `=` returns NULL (not TRUE) when either side is NULL or the + // array holds a NULL element (e.g. `{a,NULL} = {a,NULL}` is NULL), which would fail the match + // and insert a spurious new version on every re-upsert of an unchanged array. this mirrors + // the null-safety the join-table hash path already has via array_to_string(..., '__NULL__'). + if (isNativeArrayProperty({ property: definition })) + return `AND (${namespacedColumnName} IS NOT DISTINCT FROM ${columnValueReference})`; + return [ `AND (${namespacedColumnName} = ${columnValueReference}`, definition.nullable diff --git a/src/domain.operations/generate/entityTables/generateEntityTables.ts b/src/domain.operations/generate/entityTables/generateEntityTables.ts index 79ae390..b5c5440 100644 --- a/src/domain.operations/generate/entityTables/generateEntityTables.ts +++ b/src/domain.operations/generate/entityTables/generateEntityTables.ts @@ -1,5 +1,6 @@ import type { Entity, Property } from '@src/domain.objects'; import { pickKeysFromObject } from '@src/domain.operations/generate/utils/pickKeysFromObject'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; import { generateMappingTablesForArrayProperties } from './generateMappingTablesForArrayProperties'; import { generateTableForCurrentVersionPointer } from './generateTableForCurrentVersionPointer'; @@ -16,9 +17,10 @@ export const generateEntityTables = ({ entity }: { entity: Entity }) => { object: entity.properties, keep: (property: Property) => !!property.updatable, }); + // only join-table arrays need a mapping table; native arrays live inline as a column const arrayProps = pickKeysFromObject({ object: entity.properties, - keep: (property: Property) => !!property.array, + keep: (property: Property) => isJoinTableArrayProperty({ property }), }); // 2. validate the props diff --git a/src/domain.operations/generate/entityTables/generateTable/generateTable.test.ts b/src/domain.operations/generate/entityTables/generateTable/generateTable.test.ts index 67921c3..8361439 100644 --- a/src/domain.operations/generate/entityTables/generateTable/generateTable.test.ts +++ b/src/domain.operations/generate/entityTables/generateTable/generateTable.test.ts @@ -103,6 +103,40 @@ describe('generateTableConstraint', () => { }); expect(sql).not.toMatch(/^\s*,?$/m); // no lines should be empty or only contain spaces and a comma }); + it('should recast an enum check spread onto a native array after ARRAY_OF (order-independent)', () => { + // the idiomatic `{ ...ARRAY_OF(x), check }` order attaches the check AFTER ARRAY_OF returned, + // so the declare-time recast never saw it. the emission-site guard must recast it anyway. + const statusesProperty = new Property({ + ...prop.ARRAY_OF(prop.VARCHAR()), + check: "($COLUMN_NAME IN ('ACTIVE', 'OFFLINE'))", + }); + const sql = generateTable({ + tableName: 'sensor', + properties: { statuses: statusesProperty }, + unique: ['statuses'], + }); + expect(sql).toContain( + "CHECK (statuses <@ ARRAY['ACTIVE', 'OFFLINE']::varchar[])", + ); + }); + it('should throw at emission for a scalar check spread onto a native array (bypass guard)', () => { + // a scalar check spread on after ARRAY_OF would emit operator-invalid DDL (`varchar[] ~ ...`) + // that fails only at apply time; the emission-site guard must reject it fail-fast at generate. + const codesProperty = new Property({ + ...prop.ARRAY_OF(prop.VARCHAR()), + check: "($COLUMN_NAME ~ '^SN')", + }); + try { + generateTable({ + tableName: 'sensor', + properties: { codes: codesProperty }, + unique: ['codes'], + }); + throw new Error('should not reach here'); + } catch (error) { + expect(error.message).toContain('only supports the ENUM check shape'); + } + }); it('should throw an error if no unique key columns are specified', () => { try { generateTable({ diff --git a/src/domain.operations/generate/entityTables/generateTable/generateTable.ts b/src/domain.operations/generate/entityTables/generateTable/generateTable.ts index 002c9a6..f73c102 100644 --- a/src/domain.operations/generate/entityTables/generateTable/generateTable.ts +++ b/src/domain.operations/generate/entityTables/generateTable/generateTable.ts @@ -1,4 +1,6 @@ import type { Property } from '@src/domain.objects'; +import { castCheckToArrayElementMembership } from '@src/domain.operations/utils/castCheckToArrayElementMembership'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; import { defineConstraintNameSafely } from './defineConstraintNameSafely'; import { generateColumn } from './generateColumn'; @@ -54,13 +56,21 @@ export const generateTable = ({ ); // define check constraints + // - a native array column can only carry an element-membership check; the authoritative + // guard runs here (at emission) so a scalar check spread onto an array property after + // ARRAY_OF (e.g. `{ ...ARRAY_OF(x), check }`) is caught regardless of construction order. + // castCheckToArrayElementMembership is idempotent, so an already-recast `<@` check passes + // through and a scalar/custom check fails fast at generate time, not apply time. const checkConstraintSqls = Object.entries(properties) .filter((entry) => !!entry[1].check) .map((entry) => { + const check = isNativeArrayProperty({ property: entry[1] }) + ? castCheckToArrayElementMembership({ check: entry[1].check! }) + : entry[1].check!; return `CONSTRAINT ${defineConstraintNameSafely({ tableName, constraintName: `${entry[0]}_check`, - })} CHECK ${entry[1].check!.replace(/\$COLUMN_NAME/g, entry[0])}`; + })} CHECK ${check.replace(/\$COLUMN_NAME/g, entry[0])}`; }) .sort(); diff --git a/src/domain.operations/generate/entityTables/generateTableForStaticProperties.test.ts b/src/domain.operations/generate/entityTables/generateTableForStaticProperties.test.ts index 5b77fd6..625ade0 100644 --- a/src/domain.operations/generate/entityTables/generateTableForStaticProperties.test.ts +++ b/src/domain.operations/generate/entityTables/generateTableForStaticProperties.test.ts @@ -51,19 +51,19 @@ describe('generateTableForStaticProperties', () => { }), ); }); - it('should convert array properties into "hash" properties', async () => { + it('should convert join-table array properties into "hash" properties', async () => { /* purpose: - having the data hash will allow us to quickly and easily query to see if the full array is exactly equal to another row's full array + a data hash lets us quickly and easily query to see if the full array is exactly equal to another row's full array example: - if we need to be unique on the property and it happens to be an array + if we need to be unique on the property and it happens to be a join-table (reference/uuid) array */ await generateTableForStaticProperties({ entityName: '__ENTITY_NAME__', unique: ['uniqueProp'], properties: { - testProp: { type: 'TEST_PROP', array: true } as any, + testProp: prop.ARRAY_OF(prop.UUID()), // a uuid array is a join-table array -> hashed uniqueProp: '__TEST_PROP__' as any, }, }); @@ -76,11 +76,31 @@ describe('generateTableForStaticProperties', () => { }), ); }); - it('should be able to be unique on an array property', async () => { + it('should keep native array properties as real columns, not hash properties', async () => { + /* + purpose: + a native primitive/enum array is stored inline as a real array column (e.g. text[]), + so it must flow through under its own name, NOT be swapped for a values-hash column + */ + const nativeArrayProp = prop.ARRAY_OF(prop.VARCHAR()); + await generateTableForStaticProperties({ + entityName: '__ENTITY_NAME__', + unique: ['uniqueProp'], + properties: { + testProp: nativeArrayProp, + uniqueProp: '__TEST_PROP__' as any, + }, + }); + expect(generateTableMock).toHaveBeenCalledTimes(1); + const passedProperties = generateTableMock.mock.calls[0]![0].properties; + expect(passedProperties.testProp).toEqual(nativeArrayProp); // passed through as-is + expect(passedProperties.testProp_hash).toEqual(undefined); // no hash column + }); + it('should be able to be unique on a join-table array property', async () => { await generateTableForStaticProperties({ entityName: '__ENTITY_NAME__', unique: ['testProp'], - properties: { testProp: { type: 'TEST_PROP', array: true } as any }, + properties: { testProp: prop.ARRAY_OF(prop.UUID()) }, }); expect(generateTableMock).toHaveBeenCalledTimes(1); expect(generateTableMock).toHaveBeenCalledWith( diff --git a/src/domain.operations/generate/entityTables/generateTableForStaticProperties.ts b/src/domain.operations/generate/entityTables/generateTableForStaticProperties.ts index ab49414..f29b123 100644 --- a/src/domain.operations/generate/entityTables/generateTableForStaticProperties.ts +++ b/src/domain.operations/generate/entityTables/generateTableForStaticProperties.ts @@ -2,6 +2,7 @@ import { type Properties, Property } from '@src/domain.objects'; import * as prop from '@src/domain.operations/define/defineProperty'; import { castPropertyToColumnName } from '@src/domain.operations/generate/utils/castPropertyToColumnName'; import { pickKeysFromObject } from '@src/domain.operations/generate/utils/pickKeysFromObject'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; import { generateTable } from './generateTable'; import { castArrayPropertiesToValuesHashProperties } from './utils/castArrayPropertiesToValuesHashProperties'; @@ -15,14 +16,16 @@ export const generateTableForStaticProperties = ({ properties: Properties; unique: string[]; }) => { - // 0. split singular and array properties + // 0. split singular and join-table-array properties + // - native arrays flow through as real array columns, so they count as "singular" here + // - only join-table arrays are swapped for a values-hash column const staticSingularProperties = pickKeysFromObject({ object: properties, - keep: (property: Property) => !property.array, + keep: (property: Property) => !isJoinTableArrayProperty({ property }), }); const staticArrayProperties = pickKeysFromObject({ object: properties, - keep: (property: Property) => !!property.array, + keep: (property: Property) => isJoinTableArrayProperty({ property }), }); // 1. add metadata properties diff --git a/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.test.ts b/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.test.ts index 97eda5d..6889b62 100644 --- a/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.test.ts +++ b/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.test.ts @@ -49,10 +49,10 @@ describe('generateTableForUpdateableProperties', () => { }), ); }); - it('should convert array properties into "values hash" properties', async () => { + it('should convert join-table array properties into "values hash" properties', async () => { /* purpose: - having the values_hash will allow us to quickly and easily query to see if the full array is exactly equal to another row's full array + a values_hash lets us quickly and easily query to see if the full array is exactly equal to another row's full array example: if we need to determine whether or not the current version's array is equal to the array in the upsert @@ -60,7 +60,7 @@ describe('generateTableForUpdateableProperties', () => { await generateTableForUpdateableProperties({ entityName: '__ENTITY_NAME__', properties: { - testProp: { type: 'TEST_PROP', updatable: true, array: true } as any, + testProp: { ...prop.ARRAY_OF(prop.UUID()), updatable: true }, // a uuid array is a join-table array -> hashed }, }); expect(generateTableMock).toHaveBeenCalledTimes(1); @@ -72,4 +72,25 @@ describe('generateTableForUpdateableProperties', () => { }), ); }); + it('should keep native array properties as real columns, not hash properties', async () => { + /* + purpose: + a native primitive/enum array is stored inline as a real array column (e.g. text[]), + so it must flow through under its own name, NOT be swapped for a values-hash column + */ + const nativeArrayProp = { + ...prop.ARRAY_OF(prop.VARCHAR()), + updatable: true, + }; + await generateTableForUpdateableProperties({ + entityName: '__ENTITY_NAME__', + properties: { + testProp: nativeArrayProp, + }, + }); + expect(generateTableMock).toHaveBeenCalledTimes(1); + const passedProperties = generateTableMock.mock.calls[0]![0].properties; + expect(passedProperties.testProp).toEqual(nativeArrayProp); // passed through as-is + expect(passedProperties.testProp_hash).toEqual(undefined); // no hash column + }); }); diff --git a/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.ts b/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.ts index 806885c..58533e6 100644 --- a/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.ts +++ b/src/domain.operations/generate/entityTables/generateTableForUpdateableProperties.ts @@ -1,6 +1,7 @@ import { Property } from '@src/domain.objects'; import * as prop from '@src/domain.operations/define/defineProperty'; import { pickKeysFromObject } from '@src/domain.operations/generate/utils/pickKeysFromObject'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; import { generateTable } from './generateTable'; import { castArrayPropertiesToValuesHashProperties } from './utils/castArrayPropertiesToValuesHashProperties'; @@ -12,14 +13,16 @@ export const generateTableForUpdateableProperties = ({ entityName: string; properties: { [index: string]: Property }; }) => { - // 0. split singular and array properties + // 0. split singular and join-table-array properties + // - native arrays flow through as real array columns, so they count as "singular" here + // - only join-table arrays are swapped for a values-hash column const updatableSingularProperties = pickKeysFromObject({ object: properties, - keep: (property: Property) => !property.array, + keep: (property: Property) => !isJoinTableArrayProperty({ property }), }); const updatableArrayProperties = pickKeysFromObject({ object: properties, - keep: (property: Property) => !!property.array, + keep: (property: Property) => isJoinTableArrayProperty({ property }), }); // 1. add metadata properties diff --git a/src/domain.operations/generate/entityTables/utils/castArrayPropertiesToValuesHashProperties.ts b/src/domain.operations/generate/entityTables/utils/castArrayPropertiesToValuesHashProperties.ts index 4286023..725b132 100644 --- a/src/domain.operations/generate/entityTables/utils/castArrayPropertiesToValuesHashProperties.ts +++ b/src/domain.operations/generate/entityTables/utils/castArrayPropertiesToValuesHashProperties.ts @@ -1,12 +1,17 @@ import type { Property } from '@src/domain.objects'; import { prop } from '@src/domain.operations/define'; import { castPropertyToColumnName } from '@src/domain.operations/generate/utils/castPropertyToColumnName'; +import { isJoinTableArrayProperty } from '@src/domain.operations/utils/isJoinTableArrayProperty'; /* we store the hash of the array values on the tables themselves, for performance and simplicity in comparison queries even though the data is mastered through mapping tables this gives us a utility to "create a values hash property for each array property" + + note: only join-table arrays get a values-hash column. a native array is a real column and must + never be swapped for a hash, so we assert the tight precondition (isJoinTableArrayProperty) rather + than the looser `.array`, to fail loud if a future caller forgets to pre-filter. */ export const castArrayPropertiesToValuesHashProperties = ({ properties, @@ -15,9 +20,9 @@ export const castArrayPropertiesToValuesHashProperties = ({ }) => { const castedProperties: { [index: string]: Property } = {}; Object.entries(properties).forEach(([name, definition]) => { - if (!definition.array) { + if (!isJoinTableArrayProperty({ property: definition })) { throw new Error( - 'error - non array property was asked to have been casted into values hash property', + 'error - a non-join-table-array property was asked to have been casted into a values hash property', ); } const columnName = castPropertyToColumnName({ name, definition }); diff --git a/src/domain.operations/generate/entityViews/generateEntityCurrentView.ts b/src/domain.operations/generate/entityViews/generateEntityCurrentView.ts index 794f9c1..fe352d3 100644 --- a/src/domain.operations/generate/entityViews/generateEntityCurrentView.ts +++ b/src/domain.operations/generate/entityViews/generateEntityCurrentView.ts @@ -26,6 +26,10 @@ export const generateEntityCurrentView = ({ entity }: { entity: Entity }) => { const updateablePropertyNames = Object.entries(entity.properties) .filter((entry) => !!entry[1].updatable) .map((entry) => entry[0]); + // any array property forces a view, native or join-table: + // - a join-table array needs the view to collapse the join table via array_agg + // - a native array needs the view to coalesce a NULL cell to an empty array (`[]`), + // so consumers see `[]` not NULL for an unset list (per the vision's null->[] contract) const arrayPropertyNames = Object.entries(entity.properties) .filter((entry) => !!entry[1].array) .map((entry) => entry[0]); diff --git a/src/domain.operations/generate/entityViews/utils/__snapshots__/castPropertyToSelector.test.ts.snap b/src/domain.operations/generate/entityViews/utils/__snapshots__/castPropertyToSelector.test.ts.snap new file mode 100644 index 0000000..e951ca5 --- /dev/null +++ b/src/domain.operations/generate/entityViews/utils/__snapshots__/castPropertyToSelector.test.ts.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`castPropertyToSelector should collapse a join-table (reference) array via array_agg 1`] = ` +"( + SELECT coalesce(array_agg(sensor_to_user.user_id ORDER BY sensor_to_user.array_order_index), array[]::bigint[]) as array_agg + FROM sensor_to_user WHERE sensor_to_user.sensor_id = s.id +) as owner_ids" +`; diff --git a/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.test.ts b/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.test.ts new file mode 100644 index 0000000..7c92b06 --- /dev/null +++ b/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.test.ts @@ -0,0 +1,85 @@ +import { Entity, Literal } from '@src/domain.objects'; +import { prop } from '@src/domain.operations/define'; + +import { castPropertyToSelector } from './castPropertyToSelector'; + +describe('castPropertyToSelector', () => { + const user = new Literal({ + name: 'user', + properties: { name: prop.VARCHAR(255) }, + }); + const sensor = new Entity({ + name: 'sensor', + properties: { + serial_number: prop.VARCHAR(), // scalar static + labels: prop.ARRAY_OF(prop.VARCHAR()), // native array, static + tags: { ...prop.ARRAY_OF(prop.VARCHAR()), updatable: true }, // native array, version + readings: { ...prop.ARRAY_OF(prop.NUMERIC()), updatable: true }, // native array, version + note: { ...prop.VARCHAR(), updatable: true }, // scalar version + owner_ids: prop.ARRAY_OF(prop.REFERENCES(user)), // join-table array + }, + unique: ['serial_number'], + }); + + it('should select a native array (static) via a coalesce to an empty array', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'labels', + definition: sensor.properties.labels!, + }); + // coalesce a null to array[] so the DAO sees one shape ([], never null) + expect(selector).toEqual( + 'coalesce(s.labels, array[]::varchar[]) as labels', + ); + }); + + it('should select a native array (updatable) from the version table, coalesced', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'tags', + definition: sensor.properties.tags!, + }); + expect(selector).toEqual('coalesce(v.tags, array[]::varchar[]) as tags'); + }); + + it('should honor the element type of a numeric native array', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'readings', + definition: sensor.properties.readings!, + }); + expect(selector).toEqual( + 'coalesce(v.readings, array[]::numeric[]) as readings', + ); + }); + + it('should collapse a join-table (reference) array via array_agg', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'owner_ids', + definition: sensor.properties.owner_ids!, + }); + // a reference array keeps its join-table array_agg collapse (unchanged) + expect(selector).toContain('array_agg'); + expect(selector).not.toContain('coalesce(s.owner_ids'); + expect(selector).toMatchSnapshot(); + }); + + it('should select a scalar static property straight through', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'serial_number', + definition: sensor.properties.serial_number!, + }); + expect(selector).toEqual('s.serial_number'); + }); + + it('should select a scalar updatable property from the version table', () => { + const selector = castPropertyToSelector({ + entityName: 'sensor', + name: 'note', + definition: sensor.properties.note!, + }); + expect(selector).toEqual('v.note'); + }); +}); diff --git a/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.ts b/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.ts index 555fb89..c29b68a 100644 --- a/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.ts +++ b/src/domain.operations/generate/entityViews/utils/castPropertyToSelector.ts @@ -1,5 +1,7 @@ import type { Property } from '@src/domain.objects'; import { defineMappingTableKeysForEntityProperty } from '@src/domain.operations/generate/utils/defineMappingTableKeysForEntityProperty'; +import { extractDataTypeDefinitionFromProperty } from '@src/domain.operations/generate/utils/extractDataTypeDefinitionFromProperty'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; export const castPropertyToSelector = ({ entityName, @@ -10,7 +12,19 @@ export const castPropertyToSelector = ({ name: string; definition: Property; }) => { - // if property is an array, the selector should CONCAT_WS from the mapping table + // a native array is a real column - but coalesce a null to an empty array, so the DAO + // sees one shape ([], never null) across both native and join-table storage + // (this matches the join-table convention, whose array_agg also coalesces to array[]) + if (isNativeArrayProperty({ property: definition })) { + const arrayTableAlias = definition.updatable ? 'v' : 's'; + const arrayDataType = extractDataTypeDefinitionFromProperty({ + property: definition, + }); + return `coalesce(${arrayTableAlias}.${name}, array[]::${arrayDataType}) as ${name}`; + } + + // a join-table array needs the array_agg collapse handled here + // (native arrays already returned above, so any array reaching here is join-table) if (definition.array) { const mappingTableKeys = defineMappingTableKeysForEntityProperty({ entityName, diff --git a/src/domain.operations/generate/nativeArrayColumns.integration.test.ts b/src/domain.operations/generate/nativeArrayColumns.integration.test.ts new file mode 100644 index 0000000..fc1e714 --- /dev/null +++ b/src/domain.operations/generate/nativeArrayColumns.integration.test.ts @@ -0,0 +1,840 @@ +import { pg as prepare } from 'yesql'; + +import { uuid } from '@src/deps'; +import { Entity } from '@src/domain.objects'; +import * as prop from '@src/domain.operations/define/defineProperty'; +import { + createTablesForEntity, + type DatabaseConnection, + dropTablesForEntity, + getDatabaseConnection, +} from '@src/domain.operations/generate/.test.utils'; +import { dropAndCreateUpsertFunctionForEntity } from '@src/domain.operations/generate/.test.utils/dropAndCreateUpsertForEntity'; +import { dropAndCreateViewForEntity } from '@src/domain.operations/generate/.test.utils/dropAndCreateViewForEntity'; +import { getEntityFromCurrentView } from '@src/domain.operations/generate/.test.utils/getEntityFromCurrentView'; + +import { generateEntityUpsert } from './entityFunctions/generateEntityUpsert/generateEntityUpsert'; +import { generateEntityTables } from './entityTables/generateEntityTables'; +import { generateEntityCurrentView } from './entityViews/generateEntityCurrentView'; + +/* + proves the native primitive + enum array column feature end to end: + - DDL emits native array columns (text[]/numeric[]/boolean[]/timestamptz[]/[]), not join tables + - the upsert writes each array in one shot + - the hydrated view reads each array straight through + - change detection treats the array as a value (no spurious version bump) + - the enum array check rejects an out-of-set element +*/ + +// db-generated metadata that is not deterministic across runs; strip it so the domain array +// columns can be snapshotted for eyeball review. note: only these exact keys are volatile — +// `moments_at`/`moments`/`days` etc. are DOMAIN array data (fixed input instants), NOT metadata, +// so we strip by exact key name, never by an `_at` suffix. +const VOLATILE_ROW_KEYS = [ + 'id', + 'uuid', + 'created_at', + 'effective_at', + 'updated_at', +]; + +// a serial primary/foreign key value is db-generated and order-dependent (e.g. a version row's +// `sensor_id` is the parent static id), so it is volatile too. any `_id` column is such an +// fk reference, not domain array data — drop it so the snapshot is order-independent. +const isVolatileKey = (key: string) => + VOLATILE_ROW_KEYS.includes(key) || key.endsWith('_id'); + +// present a roundtripped view/version row as stable, snapshot-friendly domain data: drop the +// volatile metadata keys, and coerce the driver's array element shapes into plain JSON — numeric[] +// comes back as string[] (kept as-is), timestamptz[] as Date[] (to iso), bytea[] as Buffer[] (to hex). +const asStableRow = (row: Record) => { + const stable: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (isVolatileKey(key)) continue; + if (Array.isArray(value)) { + stable[key] = value.map((element) => { + if (element instanceof Date) return element.toISOString(); + if (Buffer.isBuffer(element)) return element.toString('hex'); + return element; + }); + } else { + stable[key] = value; + } + } + return stable; +}; +describe('native array columns', () => { + let dbConnection: DatabaseConnection; + beforeAll(async () => { + dbConnection = await getDatabaseConnection(); + }); + afterAll(async () => { + await dbConnection.end(); + }); + + const sensor = new Entity({ + name: 'sensor', + properties: { + serial_number: prop.VARCHAR(), + labels: prop.ARRAY_OF(prop.VARCHAR()), // static native array -> lives on the base table + tags: { ...prop.ARRAY_OF(prop.VARCHAR()), updatable: true }, + readings: { ...prop.ARRAY_OF(prop.NUMERIC()), updatable: true }, + flags: { ...prop.ARRAY_OF(prop.BOOLEAN()), updatable: true }, + moments_at: { ...prop.ARRAY_OF(prop.TIMESTAMPTZ()), updatable: true }, + statuses: { + ...prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'FAULTED', 'OFFLINE'])), + updatable: true, + }, + }, + unique: ['serial_number'], + }); + + beforeAll(async () => { + await dropTablesForEntity({ entity: sensor, dbConnection }); + await createTablesForEntity({ entity: sensor, dbConnection }); + await dropAndCreateUpsertFunctionForEntity({ + entity: sensor, + dbConnection, + }); + await dropAndCreateViewForEntity({ entity: sensor, dbConnection }); + }); + + const upsertSensor = async (input: { + serial_number: string; + labels: string[]; + tags: string[]; + readings: number[]; + flags: boolean[]; + moments_at: string[]; + statuses: string[]; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_${sensor.name}( + :serial_number, + :labels, + :tags, + :readings, + :flags, + :moments_at, + :statuses + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + const getVersions = async ({ id }: { id: number }) => { + const result = await dbConnection.query( + prepare(` + select * from ${sensor.name}_version where ${sensor.name}_id = :id order by created_at asc + `)({ id }), + ); + return result.rows; + }; + + it('should emit native array columns in the DDL, with no join table', () => { + const tables = generateEntityTables({ entity: sensor }); + + // the static table carries the static native array column inline + expect(tables.static.sql).toContain('labels varchar[]'); + + // the version table carries each updatable native array column inline + expect(tables.version!.sql).toContain('tags varchar[]'); + expect(tables.version!.sql).toContain('readings numeric[]'); + expect(tables.version!.sql).toContain('flags boolean[]'); + expect(tables.version!.sql).toContain( + 'moments_at timestamp with time zone[]', + ); + expect(tables.version!.sql).toContain('statuses varchar[]'); + + // the enum array uses an element-membership check, not a scalar IN + expect(tables.version!.sql).toContain( + "CHECK (statuses <@ ARRAY['ACTIVE', 'FAULTED', 'OFFLINE']::varchar[])", + ); + + // no join table is generated for any of these native arrays + expect(tables.mappings).toEqual([]); + + // no *_hash column is emitted for the native arrays + expect(tables.static.sql).not.toContain('labels_hash'); + expect(tables.version!.sql).not.toContain('tags_hash'); + }); + + it('should round-trip native arrays through upsert and the hydrated view', async () => { + const id = await upsertSensor({ + serial_number: '__SERIAL_ROUNDTRIP__', + labels: ['alpha', 'beta'], + tags: ['a', 'b'], + readings: [1, 2], + flags: [true, false], + moments_at: ['2026-01-01T00:00:00.000Z', '2026-02-01T00:00:00.000Z'], + statuses: ['ACTIVE', 'FAULTED'], + }); + + const view = await getEntityFromCurrentView({ + id, + entity: sensor, + dbConnection, + }); + + // strings + enums come back as string arrays, straight through + expect(view.labels).toEqual(['alpha', 'beta']); + expect(view.tags).toEqual(['a', 'b']); + expect(view.statuses).toEqual(['ACTIVE', 'FAULTED']); + + // booleans come back as a boolean array + expect(view.flags).toEqual([true, false]); + + // numeric comes back as a string array (node-postgres default); compare by value + expect(view.readings.map(Number)).toEqual([1, 2]); + + // timestamptz comes back as a Date array; compare by instant + expect(view.moments_at.map((d: Date) => new Date(d).getTime())).toEqual([ + new Date('2026-01-01T00:00:00.000Z').getTime(), + new Date('2026-02-01T00:00:00.000Z').getTime(), + ]); + + // snapshot the full roundtripped instance (every native array type at once) so a reviewer + // can eyeball exactly what data comes back through the view in a PR diff + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (all native array element types)', + ); + }); + + it('should not bump the version when the same array is re-upserted', async () => { + const input = { + serial_number: '__SERIAL_IDEMPOTENT__', + labels: ['x'], + tags: ['keep', 'same'], + readings: [3, 4], + flags: [true], + moments_at: ['2026-03-01T00:00:00.000Z'], + statuses: ['OFFLINE'], + }; + + const id = await upsertSensor(input); + const versionsAfterFirst = await getVersions({ id }); + expect(versionsAfterFirst.length).toEqual(1); + + // re-upsert the exact same values -> no new version + await upsertSensor(input); + const versionsAfterSecond = await getVersions({ id }); + expect(versionsAfterSecond.length).toEqual(1); + }); + + it('should bump the version exactly once for a change in each updatable array element type', async () => { + // a baseline for every updatable native array type (varchar, numeric, boolean, + // timestamptz, enum). each step below changes exactly ONE type and asserts one bump, + // so the changed-direction is proven per element type, not just for varchar `tags`. + const base = { + serial_number: '__SERIAL_CHANGE__', + labels: ['fixed'], + tags: ['before'], + readings: [5], + flags: [false], + moments_at: ['2026-04-01T00:00:00.000Z'], + statuses: ['ACTIVE'], + }; + const id = await upsertSensor(base); + expect((await getVersions({ id })).length).toEqual(1); + + // varchar[] change + await upsertSensor({ ...base, tags: ['after'] }); + expect((await getVersions({ id })).length).toEqual(2); + + // numeric[] change + await upsertSensor({ ...base, tags: ['after'], readings: [6] }); + expect((await getVersions({ id })).length).toEqual(3); + + // boolean[] change + await upsertSensor({ + ...base, + tags: ['after'], + readings: [6], + flags: [true], + }); + expect((await getVersions({ id })).length).toEqual(4); + + // timestamptz[] change + await upsertSensor({ + ...base, + tags: ['after'], + readings: [6], + flags: [true], + moments_at: ['2026-05-01T00:00:00.000Z'], + }); + expect((await getVersions({ id })).length).toEqual(5); + + // enum varchar[] change + await upsertSensor({ + ...base, + tags: ['after'], + readings: [6], + flags: [true], + moments_at: ['2026-05-01T00:00:00.000Z'], + statuses: ['FAULTED'], + }); + expect((await getVersions({ id })).length).toEqual(6); + + // snapshot every version row in order so a reviewer sees the full before->after progression + // of the stored native-array data as each element type changes, one at a time + const versions = await getVersions({ id }); + expect(versions.map(asStableRow)).toMatchSnapshot( + 'version rows across a change in each updatable native array element type', + ); + }); + + it('should snapshot the generated DDL, upsert function, and view for eyeball review', () => { + const tables = generateEntityTables({ entity: sensor }); + const upsert = generateEntityUpsert({ entity: sensor }); + const view = generateEntityCurrentView({ entity: sensor }); + + // snapshot the actual generated SQL so a reviewer can eyeball the native-array codegen in a diff + expect(tables.static.sql).toMatchSnapshot(); + expect(tables.version!.sql).toMatchSnapshot(); + expect(upsert.sql).toMatchSnapshot(); + expect(view!.sql).toMatchSnapshot(); + }); + + it('should accept an explicit empty enum array through the element-membership check', async () => { + // the enum-array CHECK is `statuses <@ ARRAY[...]`; an empty array `{}` is contained by any + // set, so it must pass the check (proven through the real upsert, not just asserted in a comment) + const id = await upsertSensor({ + serial_number: '__SERIAL_EMPTY_ENUM__', + labels: [], + tags: [], + readings: [], + flags: [], + moments_at: [], + statuses: [], // empty enum array must satisfy the <@ check + }); + + const view = await getEntityFromCurrentView({ + id, + entity: sensor, + dbConnection, + }); + expect(view.statuses).toEqual([]); + + // boundary: an all-empty-arrays instance reads back as empty arrays (not null) across every + // native array element type — snapshot the whole instance so the empty-array shape is visible + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (all empty arrays)', + ); + }); + + it('should reject an enum array element outside the allowed set', async () => { + try { + await upsertSensor({ + serial_number: '__SERIAL_BAD_ENUM__', + labels: ['y'], + tags: ['t'], + readings: [9], + flags: [true], + moments_at: ['2026-05-01T00:00:00.000Z'], + statuses: ['NOT_A_REAL_STATUS'], // violates the element-membership check + }); + throw new Error('should not reach here'); // fail if the check did not reject + } catch (error) { + expect(error.message).toContain('statuses_check'); + expect(error.message).toMatchSnapshot(); // the exact postgres error a developer sees + } + }); + + // pin down the `<@` NULL-element semantics against a real postgres: an enum array + // that holds a NULL element does NOT satisfy `col <@ ARRAY[...]`, so the CHECK rejects + // it — the same fail-loud outcome as an out-of-set element. this is the pit-of-success + // (a stray NULL cannot slip into an enum list) and documents the behavior so a future + // change to the check shape is caught. + it('should reject an enum array that holds a NULL element', async () => { + try { + await dbConnection.query( + prepare(` + SELECT * FROM upsert_${sensor.name}( + :serial_number, + :labels, + :tags, + :readings, + :flags, + :moments_at, + :statuses + ); + `)({ + serial_number: '__SERIAL_NULL_ENUM_ELEMENT__', + labels: ['y'], + tags: ['t'], + readings: [9], + flags: [true], + moments_at: ['2026-05-01T00:00:00.000Z'], + statuses: ['ACTIVE', null], // NULL element -> <@ not satisfied -> CHECK rejects + }), + ); + throw new Error('should not reach here'); // fail if the check did not reject + } catch (error) { + expect(error.message).toContain('statuses_check'); + // snapshot the exact postgres error a developer sees when a NULL element slips into an + // enum array — same fail-loud check violation as an out-of-set element + expect(error.message).toMatchSnapshot( + 'enum array NULL-element rejection error', + ); + } + }); + + // an entity whose only special property is a static native array, with no updatable + // properties (so no version table). this exercises the path where a view must still be + // generated: the view carries the null->[] coalesce that presents an unset list as []. + describe('static native array only entity (no version table)', () => { + const beacon = new Entity({ + name: 'beacon', + properties: { + serial_number: prop.VARCHAR(), + // static native array; no updatable props. nullable so the null->[] coalesce + // in the view is reachable (a non-nullable array column can never hold NULL) + labels: { ...prop.ARRAY_OF(prop.VARCHAR()), nullable: true }, + }, + unique: ['serial_number'], + }); + + beforeAll(async () => { + await dropTablesForEntity({ entity: beacon, dbConnection }); + await createTablesForEntity({ entity: beacon, dbConnection }); + await dropAndCreateUpsertFunctionForEntity({ + entity: beacon, + dbConnection, + }); + await dropAndCreateViewForEntity({ entity: beacon, dbConnection }); + }); + + const upsertBeacon = async (input: { + serial_number: string; + labels: string[]; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_${beacon.name}( + :serial_number, + :labels + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + it('should still generate a view even with no version table', () => { + const view = generateEntityCurrentView({ entity: beacon }); + expect(view).not.toEqual(null); + }); + + it('should round-trip the static native array through the view', async () => { + const id = await upsertBeacon({ + serial_number: '__BEACON_ROUNDTRIP__', + labels: ['alpha', 'beta'], + }); + const view = await getEntityFromCurrentView({ + id, + entity: beacon, + dbConnection, + }); + expect(view.labels).toEqual(['alpha', 'beta']); + + // boundary: a static-native-array-only entity (no version table) still roundtrips the + // array through its view — snapshot the instance + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (static native array only, no version table)', + ); + }); + + it('should present a NULL native array cell as an empty array via the view', async () => { + // insert a base row directly, with labels unset (NULL), to prove the coalesce + const inserted = await dbConnection.query( + prepare(` + INSERT INTO ${beacon.name} (uuid, serial_number) + VALUES (uuid_generate_v4(), :serial_number) + RETURNING id + `)({ serial_number: '__BEACON_NULL_LABELS__' }), + ); + const id = inserted.rows[0].id as number; + + const view = await getEntityFromCurrentView({ + id, + entity: beacon, + dbConnection, + }); + expect(view.labels).toEqual([]); // NULL coalesced to [] by the view + + // boundary: a NULL native array cell presents as [] through the view's coalesce — + // snapshot the instance so the null->[] projection is visible + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (NULL native array cell coalesced to [])', + ); + }); + }); + + // an entity that mixes a native array (labels -> a real text[] column) with a join-table + // array (part_uuids -> a child map-table). the vision names this coexistence as an edgecase + // that must work without collision: the two storage models must live side by side in the + // same DDL, upsert, and view without a column-name, check-name, or join-order clash. + describe('entity mixing native and join-table arrays', () => { + const gadget = new Entity({ + name: 'gadget', + properties: { + serial_number: prop.VARCHAR(), + labels: prop.ARRAY_OF(prop.VARCHAR()), // native array -> text[] column + part_uuids: prop.ARRAY_OF(prop.UUID()), // join-table array -> child map-table + }, + unique: ['serial_number'], + }); + + beforeAll(async () => { + await dropTablesForEntity({ entity: gadget, dbConnection }); + await createTablesForEntity({ entity: gadget, dbConnection }); + await dropAndCreateUpsertFunctionForEntity({ + entity: gadget, + dbConnection, + }); + await dropAndCreateViewForEntity({ entity: gadget, dbConnection }); + }); + + const upsertGadget = async (input: { + serial_number: string; + labels: string[]; + part_uuids: string; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_${gadget.name}( + :serial_number, + :labels, + :part_uuids + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + it('should generate a native array column and a join table side by side, no collision', () => { + const tables = generateEntityTables({ entity: gadget }); + + // the native array lives inline on the base table as a real column + expect(tables.static.sql).toContain('labels varchar[]'); + expect(tables.static.sql).not.toContain('labels_hash'); + + // the join-table array gets a values-hash column plus one mapping table + expect(tables.static.sql).toContain('part_uuids_hash'); + expect(tables.mappings.length).toEqual(1); + expect(tables.mappings[0]!.name).toContain('part_uuid'); + }); + + it('should round-trip both arrays through upsert and the view without collision', async () => { + const partUuids = [uuid(), uuid()]; + const id = await upsertGadget({ + serial_number: '__GADGET_MIXED__', + labels: ['alpha', 'beta'], + part_uuids: `{${partUuids.join(',')}}`, + }); + + const view = await getEntityFromCurrentView({ + id, + entity: gadget, + dbConnection, + }); + + // the native array reads straight through as a text[] + expect(view.labels).toEqual(['alpha', 'beta']); + + // the join-table array reads back collapsed via the view, same values + expect(`{${view.part_uuids.join(',')}}`).toEqual( + `{${partUuids.join(',')}}`, + ); + + // boundary: native array + join-table array coexist without collision. the part_uuids are + // random per run, so snapshot the deterministic shape — the native `labels` verbatim plus the + // join-array element count — to show both storage models roundtrip side by side + expect({ + labels: view.labels, + part_uuids_count: view.part_uuids.length, + }).toMatchSnapshot( + 'roundtrip view instance (native array + join-table array)', + ); + }); + }); + + // proves that every documented primitive element type (beyond the five the sensor exercises) + // produces valid postgres array DDL and round-trips. real postgres validates each column type + // when createTablesForEntity runs in the beforeAll; a bad type string would throw there. + describe('broad primitive element types', () => { + const probe = new Entity({ + name: 'probe', + properties: { + serial_number: prop.VARCHAR(), + shorts: prop.ARRAY_OF(prop.SMALLINT()), + ints: prop.ARRAY_OF(prop.INT()), + bigs: prop.ARRAY_OF(prop.BIGINT()), + reals: prop.ARRAY_OF(prop.REAL()), + doubles: prop.ARRAY_OF(prop.DOUBLE_PRECISION()), + codes: prop.ARRAY_OF(prop.CHAR(3)), + notes: prop.ARRAY_OF(prop.TEXT()), + prices: prop.ARRAY_OF(prop.NUMERIC(10, 2)), // precision + scale composed with [] + days: prop.ARRAY_OF(prop.DATE()), + times: prop.ARRAY_OF(prop.TIME()), + moments: prop.ARRAY_OF(prop.TIMESTAMP()), + blobs: prop.ARRAY_OF(prop.BYTEA()), + }, + unique: ['serial_number'], + }); + + beforeAll(async () => { + await dropTablesForEntity({ entity: probe, dbConnection }); + await createTablesForEntity({ entity: probe, dbConnection }); // real pg validates each array type + await dropAndCreateUpsertFunctionForEntity({ + entity: probe, + dbConnection, + }); + await dropAndCreateViewForEntity({ entity: probe, dbConnection }); + }); + + const upsertProbe = async (input: { + serial_number: string; + shorts: string; + ints: string; + bigs: string; + reals: string; + doubles: string; + codes: string; + notes: string; + prices: string; + days: string; + times: string; + moments: string; + blobs: string; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_${probe.name}( + :serial_number, + :shorts, + :ints, + :bigs, + :reals, + :doubles, + :codes, + :notes, + :prices, + :days, + :times, + :moments, + :blobs + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + it('should compose precision + scale with the array suffix in the DDL', () => { + const tables = generateEntityTables({ entity: probe }); + expect(tables.static.sql).toContain('prices numeric(10, 2)[]'); + expect(tables.static.sql).toContain('doubles double precision[]'); + expect(tables.static.sql).toContain('bigs bigint[]'); + expect(tables.static.sql).toContain('codes varchar(3)[]'); // prop.CHAR aliases varchar(n) + expect(tables.static.sql).toContain('blobs bytea[]'); + }); + + it('should round-trip the broad primitive array types through the view', async () => { + const id = await upsertProbe({ + serial_number: '__PROBE_ROUNDTRIP__', + shorts: '{1,2}', + ints: '{10,20}', + bigs: '{1000,2000}', + reals: '{1.5,2.5}', + doubles: '{3.25,4.75}', + codes: '{abc,xyz}', + notes: '{hello,world}', + prices: '{10.25,20.50}', + days: '{2026-01-01,2026-02-01}', + times: '{14:30:00,09:00:00}', + moments: '{2026-01-01 00:00:00,2026-02-01 00:00:00}', + blobs: '{"\\\\x0102","\\\\x0304"}', + }); + + const view = await getEntityFromCurrentView({ + id, + entity: probe, + dbConnection, + }); + + expect(view.shorts.map(Number)).toEqual([1, 2]); + expect(view.ints.map(Number)).toEqual([10, 20]); + expect(view.bigs.map(Number)).toEqual([1000, 2000]); + expect(view.reals.map(Number)).toEqual([1.5, 2.5]); + expect(view.doubles.map(Number)).toEqual([3.25, 4.75]); + expect(view.codes).toEqual(['abc', 'xyz']); + expect(view.notes).toEqual(['hello', 'world']); + expect(view.prices.map(Number)).toEqual([10.25, 20.5]); + expect(view.days.map((d: Date) => new Date(d).getTime())).toEqual([ + new Date('2026-01-01T00:00:00.000Z').getTime(), + new Date('2026-02-01T00:00:00.000Z').getTime(), + ]); + // time[] reads back as its element clock strings + expect(view.times).toEqual(['14:30:00', '09:00:00']); + // timestamp[] reads back as element dates (compared by instant) + expect(view.moments.map((m: Date) => new Date(m).getTime())).toEqual([ + new Date('2026-01-01T00:00:00.000Z').getTime(), + new Date('2026-02-01T00:00:00.000Z').getTime(), + ]); + // bytea[] reads back as element buffers (compared by hex) + expect(view.blobs.map((b: Buffer) => b.toString('hex'))).toEqual([ + '0102', + '0304', + ]); + + // snapshot the full instance across every documented primitive element type (smallint, int, + // bigint, real, double, char, text, numeric(p,s), date, time, timestamp, bytea) at once + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (broad primitive element types)', + ); + }); + + it('should round-trip an explicit empty array as an empty array', async () => { + const id = await upsertProbe({ + serial_number: '__PROBE_EMPTY__', + shorts: '{}', + ints: '{}', + bigs: '{}', + reals: '{}', + doubles: '{}', + codes: '{}', + notes: '{}', + prices: '{}', + days: '{}', + times: '{}', + moments: '{}', + blobs: '{}', + }); + + const view = await getEntityFromCurrentView({ + id, + entity: probe, + dbConnection, + }); + + // an explicitly-upserted empty array reads back as an empty array, not null + expect(view.ints).toEqual([]); + expect(view.notes).toEqual([]); + expect(view.prices).toEqual([]); + + // boundary: every broad-primitive column upserted empty reads back empty — snapshot the + // whole instance so the empty-array shape is visible across all element types at once + expect(asStableRow(view)).toMatchSnapshot( + 'roundtrip view instance (broad primitive types, all empty)', + ); + }); + }); + + // an updatable + nullable native array exercises the CVP change-detection null<->value + // transition: the where-clause conditional must add its null-safe branch for the array column. + describe('nullable updatable native array (CVP null<->value transition)', () => { + const flux = new Entity({ + name: 'flux', + properties: { + serial_number: prop.VARCHAR(), + maybe_tags: { + ...prop.ARRAY_OF(prop.VARCHAR()), + updatable: true, + nullable: true, + }, + }, + unique: ['serial_number'], + }); + + beforeAll(async () => { + await dropTablesForEntity({ entity: flux, dbConnection }); + await createTablesForEntity({ entity: flux, dbConnection }); + await dropAndCreateUpsertFunctionForEntity({ + entity: flux, + dbConnection, + }); + await dropAndCreateViewForEntity({ entity: flux, dbConnection }); + }); + + const upsertFlux = async (input: { + serial_number: string; + maybe_tags: string[] | null; + }) => { + const result = await dbConnection.query( + prepare(` + SELECT * FROM upsert_${flux.name}( + :serial_number, + :maybe_tags + ); + `)(input), + ); + return result.rows[0].id as number; + }; + + const getVersions = async ({ id }: { id: number }) => { + const result = await dbConnection.query( + prepare(` + select * from ${flux.name}_version where ${flux.name}_id = :id order by created_at asc + `)({ id }), + ); + return result.rows; + }; + + it('should bump the version across value<->null transitions, but not on a no-op', async () => { + // v1: a value + const id = await upsertFlux({ + serial_number: '__FLUX__', + maybe_tags: ['a', 'b'], + }); + expect((await getVersions({ id })).length).toEqual(1); + + // value -> null is a change -> v2 + await upsertFlux({ serial_number: '__FLUX__', maybe_tags: null }); + expect((await getVersions({ id })).length).toEqual(2); + + // null -> null is a no-op -> still v2 (the null-safe branch matches) + await upsertFlux({ serial_number: '__FLUX__', maybe_tags: null }); + expect((await getVersions({ id })).length).toEqual(2); + + // null -> value is a change -> v3 + await upsertFlux({ serial_number: '__FLUX__', maybe_tags: ['a', 'b'] }); + expect((await getVersions({ id })).length).toEqual(3); + + // value -> same value is a no-op -> still v3 + await upsertFlux({ serial_number: '__FLUX__', maybe_tags: ['a', 'b'] }); + expect((await getVersions({ id })).length).toEqual(3); + + // snapshot the three stored version rows so a reviewer sees the value -> null -> value + // progression that the CVP null-safe change detection produced + const versions = await getVersions({ id }); + expect(versions.map(asStableRow)).toMatchSnapshot( + 'version rows across value<->null transitions (nullable native array)', + ); + }); + + it('should not bump the version when an unchanged array holds a NULL element', async () => { + // a NULL *element* (distinct from a NULL whole-array) is the case where plain `=` returns + // NULL rather than TRUE; IS NOT DISTINCT FROM must still treat it as unchanged + const id = await upsertFlux({ + serial_number: '__FLUX_NULL_ELEMENT__', + maybe_tags: ['a', null] as unknown as string[], + }); + expect((await getVersions({ id })).length).toEqual(1); + + // re-upsert the identical NULL-element array -> no new version + await upsertFlux({ + serial_number: '__FLUX_NULL_ELEMENT__', + maybe_tags: ['a', null] as unknown as string[], + }); + expect((await getVersions({ id })).length).toEqual(1); + + // snapshot the single stored version row so the persisted NULL-element array shape is + // visible (a NULL element survives roundtrip and does not spuriously bump the version) + const versions = await getVersions({ id }); + expect(versions.map(asStableRow)).toMatchSnapshot( + 'version row holding a NULL array element (unchanged, no bump)', + ); + }); + }); +}); diff --git a/src/domain.operations/generate/utils/castPropertyToColumnName.ts b/src/domain.operations/generate/utils/castPropertyToColumnName.ts index c1eb606..d06cc11 100644 --- a/src/domain.operations/generate/utils/castPropertyToColumnName.ts +++ b/src/domain.operations/generate/utils/castPropertyToColumnName.ts @@ -1,7 +1,8 @@ import type { Property } from '@src/domain.objects'; +import { isNativeArrayProperty } from '@src/domain.operations/utils/isNativeArrayProperty'; /* - note: "column name" refers to the name of the column on either the static or the version table - not the mapping table (since mapping tables are always fk's and fk's have standard notation) + note: "column name" refers to the name of the column on either the static or the version table - not the join table (since join tables are always fk's and fk's have standard notation) */ export const castPropertyToColumnName = ({ name, @@ -10,7 +11,10 @@ export const castPropertyToColumnName = ({ name: string; definition: Property; }) => { - // if its an array, then we really only store the "hash" on the column - and name it that way. (the actual values are stored in a mapping table) + // a native array is stored as a real array column, so it keeps its own name + if (isNativeArrayProperty({ property: definition })) return name; // e.g., 'tags' => 'tags' (a text[] column) + + // a join-table array only stores the "hash" on the column - and is named that way (the actual values live in a join table) if (definition.array) return `${name}_hash`; // e.g., 'tag_ids' => 'tag_ids_hash' // if its not an array, then we store exactly what the user asked for diff --git a/src/domain.operations/utils/__snapshots__/castCheckToArrayElementMembership.test.ts.snap b/src/domain.operations/utils/__snapshots__/castCheckToArrayElementMembership.test.ts.snap new file mode 100644 index 0000000..839292e --- /dev/null +++ b/src/domain.operations/utils/__snapshots__/castCheckToArrayElementMembership.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`castCheckToArrayElementMembership should throw for a custom / scalar check that does not fit the enum shape 1`] = ` +"User input error. a native array column only supports the ENUM check shape; a custom or scalar check cannot be applied to a native array column. + +For potential solutions, consider the following: +- a scalar check (e.g. a regex or inequality) is operator-invalid against an array column and would fail only at apply time. +- drop the custom check, or express the constraint via prop.ENUM([...]) for element-membership. +" +`; diff --git a/src/domain.operations/utils/castCheckToArrayElementMembership.test.ts b/src/domain.operations/utils/castCheckToArrayElementMembership.test.ts new file mode 100644 index 0000000..2b12e61 --- /dev/null +++ b/src/domain.operations/utils/castCheckToArrayElementMembership.test.ts @@ -0,0 +1,29 @@ +import { castCheckToArrayElementMembership } from './castCheckToArrayElementMembership'; + +describe('castCheckToArrayElementMembership', () => { + it('should recast an enum scalar IN check into an element-membership check', () => { + const result = castCheckToArrayElementMembership({ + check: "($COLUMN_NAME IN ('ACTIVE', 'FAULTED', 'OFFLINE'))", + }); + expect(result).toEqual( + "($COLUMN_NAME <@ ARRAY['ACTIVE', 'FAULTED', 'OFFLINE']::varchar[])", + ); + }); + it('should pass through an already-recast element-membership check unchanged (idempotent)', () => { + const alreadyRecast = + "($COLUMN_NAME <@ ARRAY['ACTIVE', 'FAULTED', 'OFFLINE']::varchar[])"; + // the recast runs twice (declare-time + emission-time), so it must be safe to re-run + expect(castCheckToArrayElementMembership({ check: alreadyRecast })).toEqual( + alreadyRecast, + ); + }); + it('should throw for a custom / scalar check that does not fit the enum shape', () => { + try { + castCheckToArrayElementMembership({ check: "($COLUMN_NAME ~ '^SN')" }); + throw new Error('should not reach here'); + } catch (error) { + expect(error.message).toContain('only supports the ENUM check shape'); + expect(error.message).toMatchSnapshot(); + } + }); +}); diff --git a/src/domain.operations/utils/castCheckToArrayElementMembership.ts b/src/domain.operations/utils/castCheckToArrayElementMembership.ts new file mode 100644 index 0000000..e9d5dc4 --- /dev/null +++ b/src/domain.operations/utils/castCheckToArrayElementMembership.ts @@ -0,0 +1,49 @@ +import { UserInputError } from '@src/utils/errors/UserInputError'; + +/** + * recasts a scalar enum check into an element-membership check for a native array column. + * + * the solo ENUM path emits `($COLUMN_NAME IN ('A', 'B'))`, a scalar test that cannot apply + * to an array column. for a native `[]` column, each element must be in the allowed + * set, expressed as `($COLUMN_NAME <@ ARRAY['A', 'B']::varchar[])` (the column is contained + * by the allowed set). the `$COLUMN_NAME` placeholder is substituted at DDL output time. + * + * this is idempotent: a check already in the `<@` element-membership form is returned + * unchanged, so it is safe to apply both at declare time (early feedback) and again at + * DDL emission (the authoritative guard) without a double transform. + * + * note: + * - a NULL array passes the CHECK (postgres treats a NULL/unknown CHECK result as satisfied), + * consistent with the nullable-column contract. + * - an empty array `{}` is contained by any set, so it passes. + * - a check that does not fit the enum `IN (...)` shape is rejected (fail-fast): a scalar or + * custom check left on an array column would be operator-invalid DDL and fail only at apply + * time with a cryptic postgres error. only the ENUM path is supported for arrays. + */ +export const castCheckToArrayElementMembership = ({ + check, +}: { + check: string; +}): string => { + // already in element-membership form (recast earlier) -> idempotent passthrough + if (/\$COLUMN_NAME <@ ARRAY\[/.test(check)) return check; + + // the enum scalar `IN (...)` form -> recast to element-membership + const enumInPattern = /\$COLUMN_NAME IN \(([^)]*)\)/; + const found = enumInPattern.exec(check); + if (!found) + throw new UserInputError({ + reason: + 'a native array column only supports the ENUM check shape; a custom or scalar check cannot be applied to a native array column', + potentialSolution: [ + '', + '- a scalar check (e.g. a regex or inequality) is operator-invalid against an array column and would fail only at apply time.', + '- drop the custom check, or express the constraint via prop.ENUM([...]) for element-membership.', + ].join('\n'), + }); + const valuesList = found[1]; + return check.replace( + enumInPattern, + `$COLUMN_NAME <@ ARRAY[${valuesList}]::varchar[]`, + ); +}; diff --git a/src/domain.operations/utils/isJoinTableArrayProperty.test.ts b/src/domain.operations/utils/isJoinTableArrayProperty.test.ts new file mode 100644 index 0000000..827f2a7 --- /dev/null +++ b/src/domain.operations/utils/isJoinTableArrayProperty.test.ts @@ -0,0 +1,48 @@ +import { Literal } from '@src/domain.objects'; +import { prop } from '@src/domain.operations/define'; + +import { isJoinTableArrayProperty } from './isJoinTableArrayProperty'; + +describe('isJoinTableArrayProperty', () => { + describe('join-table array elements', () => { + const user = new Literal({ + name: 'user', + properties: { name: prop.VARCHAR(255) }, + }); + it('should treat a REFERENCES array as a join-table array', () => { + expect( + isJoinTableArrayProperty({ + property: prop.ARRAY_OF(prop.REFERENCES(user)), + }), + ).toEqual(true); + }); + it('should treat a UUID array as a join-table array', () => { + expect( + isJoinTableArrayProperty({ property: prop.ARRAY_OF(prop.UUID()) }), + ).toEqual(true); + }); + }); + + describe('native array elements', () => { + it('should treat a VARCHAR array as NOT a join-table array (it is native)', () => { + expect( + isJoinTableArrayProperty({ property: prop.ARRAY_OF(prop.VARCHAR()) }), + ).toEqual(false); + }); + it('should treat an ENUM array as NOT a join-table array (it is native)', () => { + expect( + isJoinTableArrayProperty({ + property: prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'OFFLINE'])), + }), + ).toEqual(false); + }); + }); + + describe('non-array properties', () => { + it('should treat a scalar VARCHAR as NOT a join-table array', () => { + expect(isJoinTableArrayProperty({ property: prop.VARCHAR() })).toEqual( + false, + ); + }); + }); +}); diff --git a/src/domain.operations/utils/isJoinTableArrayProperty.ts b/src/domain.operations/utils/isJoinTableArrayProperty.ts new file mode 100644 index 0000000..2cad320 --- /dev/null +++ b/src/domain.operations/utils/isJoinTableArrayProperty.ts @@ -0,0 +1,22 @@ +import type { Property } from '@src/domain.objects'; + +import { isNativeArrayProperty } from './isNativeArrayProperty'; + +/** + * .what = decides whether an array property is stored via a join table (a child + * map-table plus a hash column) rather than a native postgres array column + * + * .why = arrays split into two storage models by element kind: + * - reference/uuid element => join table, for element-level references + * - primitive/enum element => native array column on the base/version table + * this is the inverse of isNativeArrayProperty, scoped to array properties. it names + * the "join-table array" concept once, so the seven generate-seams that route on it + * share a single classifier and cannot drift on the negation. + * + * .note = a non-array property is never a join-table array (returns false). + */ +export const isJoinTableArrayProperty = ({ + property, +}: { + property: Property; +}): boolean => !!property.array && !isNativeArrayProperty({ property }); diff --git a/src/domain.operations/utils/isNativeArrayProperty.test.ts b/src/domain.operations/utils/isNativeArrayProperty.test.ts new file mode 100644 index 0000000..fcea503 --- /dev/null +++ b/src/domain.operations/utils/isNativeArrayProperty.test.ts @@ -0,0 +1,70 @@ +import { Literal } from '@src/domain.objects'; +import { prop } from '@src/domain.operations/define'; + +import { isNativeArrayProperty } from './isNativeArrayProperty'; + +describe('isNativeArrayProperty', () => { + describe('native array elements', () => { + it('should treat a VARCHAR array as native', () => { + expect( + isNativeArrayProperty({ property: prop.ARRAY_OF(prop.VARCHAR()) }), + ).toEqual(true); + }); + it('should treat a NUMERIC array as native', () => { + expect( + isNativeArrayProperty({ property: prop.ARRAY_OF(prop.NUMERIC()) }), + ).toEqual(true); + }); + it('should treat a BOOLEAN array as native', () => { + expect( + isNativeArrayProperty({ property: prop.ARRAY_OF(prop.BOOLEAN()) }), + ).toEqual(true); + }); + it('should treat a TIMESTAMPTZ array as native', () => { + expect( + isNativeArrayProperty({ property: prop.ARRAY_OF(prop.TIMESTAMPTZ()) }), + ).toEqual(true); + }); + it('should treat an ENUM array as native', () => { + expect( + isNativeArrayProperty({ + property: prop.ARRAY_OF(prop.ENUM(['ACTIVE', 'OFFLINE'])), + }), + ).toEqual(true); + }); + }); + + describe('join-table array elements', () => { + const user = new Literal({ + name: 'user', + properties: { name: prop.VARCHAR(255) }, + }); + it('should treat a REFERENCES array as NOT native (it stays a join table)', () => { + expect( + isNativeArrayProperty({ + property: prop.ARRAY_OF(prop.REFERENCES(user)), + }), + ).toEqual(false); + }); + it('should treat a UUID array as NOT native (it stays a join table)', () => { + // this also locks the order invariant: a uuid array IS an array, but the + // uuid check fires before the native fallthrough, so it is never mis-routed native + expect( + isNativeArrayProperty({ property: prop.ARRAY_OF(prop.UUID()) }), + ).toEqual(false); + }); + }); + + describe('non-array properties', () => { + it('should treat a scalar VARCHAR as NOT native', () => { + expect(isNativeArrayProperty({ property: prop.VARCHAR() })).toEqual( + false, + ); + }); + it('should treat a scalar ENUM as NOT native', () => { + expect( + isNativeArrayProperty({ property: prop.ENUM(['ACTIVE', 'OFFLINE']) }), + ).toEqual(false); + }); + }); +}); diff --git a/src/domain.operations/utils/isNativeArrayProperty.ts b/src/domain.operations/utils/isNativeArrayProperty.ts new file mode 100644 index 0000000..7011d6c --- /dev/null +++ b/src/domain.operations/utils/isNativeArrayProperty.ts @@ -0,0 +1,32 @@ +import { DataTypeName, type Property } from '@src/domain.objects'; + +/** + * .what = decides whether an array property is stored as a native postgres array + * column (e.g. `text[]`, `numeric[]`, `[]`) rather than a join table + * + * .why = arrays split into two storage models by element kind: + * - primitive/enum element => native array column on the base/version table + * - reference/uuid element => join table, for element-level references + * the join table model exists because postgres cannot put a foreign-key constraint + * on an array element; primitive/enum lists have no such need, so they store inline. + * + * .note = the ref/uuid checks come FIRST so that reference and uuid arrays are never + * mis-routed to the native path (they keep their extant join table behavior). + */ +export const isNativeArrayProperty = ({ + property, +}: { + property: Property; +}): boolean => { + // only array properties can be native arrays + if (!property.array) return false; + + // reference arrays are join tables (element-level foreign keys) + if (property.references) return false; + + // uuid arrays are join tables (implicit cross-database references) + if (property.type.name === DataTypeName.UUID) return false; + + // otherwise it is a primitive or enum element -> native array column + return true; +}; diff --git a/src/utils/errors/UserInputError.ts b/src/utils/errors/UserInputError.ts index 5ef984c..c020e35 100644 --- a/src/utils/errors/UserInputError.ts +++ b/src/utils/errors/UserInputError.ts @@ -10,20 +10,25 @@ export class UserInputError extends Error { domainObjectPropertyName?: string; potentialSolution?: string; }) { - super( - [ - 'User input error.', - `${reason.replace(/\.$/, '')}.`, - domainObjectName - ? `'${domainObjectName}${ - domainObjectPropertyName ? `.${domainObjectPropertyName}` : '' - }' does not meet this criteria.` - : undefined, - potentialSolution - ? `\n\nFor potential solutions, consider the following:${potentialSolution}` - : undefined, - '\n', - ].join(''), - ); + // build the sentences a human reads, then join with a space so the class-name + // prefix does not glue onto the reason (e.g. "error.prop.ARRAY_OF" reads as one + // dotted token). undefined clauses are filtered so no double space appears. + const sentences = [ + 'User input error.', + `${reason.replace(/\.$/, '')}.`, + domainObjectName + ? `'${domainObjectName}${ + domainObjectPropertyName ? `.${domainObjectPropertyName}` : '' + }' does not meet this criteria.` + : undefined, + ].filter((part): part is string => part !== undefined); + + // the solution block carries its own newlines up front; append it (and the + // final newline) directly so no stray space precedes a line break. + const solution = potentialSolution + ? `\n\nFor potential solutions, consider the following:${potentialSolution}` + : ''; + + super(`${sentences.join(' ')}${solution}\n`); } }