@githolon/dsl 0.100.5 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dart/CHANGELOG.md +12 -0
  2. package/dart/lib/src/dev_cloud.dart +46 -15
  3. package/dart/lib/src/test_harness.dart +220 -71
  4. package/dart/lib/src/webview_bridge.dart +640 -126
  5. package/dart/pubspec.lock +1 -1
  6. package/dart/pubspec.yaml +2 -2
  7. package/dart/test/acquire_session_credentials_test.dart +1 -1
  8. package/dart/test/adv_realm_interference_test.dart +1 -1
  9. package/dart/test/bundle_version_pairing_test.dart +11 -22
  10. package/dart/test/cluster_attested_test.dart +1 -1
  11. package/dart/test/cluster_births_test.dart +1 -1
  12. package/dart/test/cluster_device_keyed_acceptance_test.dart +1 -1
  13. package/dart/test/cluster_local_autosync_test.dart +1 -1
  14. package/dart/test/cluster_scale_test.dart +1 -1
  15. package/dart/test/co2_product_gauntlet_test.dart +228 -0
  16. package/dart/test/dlq_session_surface_test.dart +1 -1
  17. package/dart/test/errors_test.dart +63 -12
  18. package/dart/test/first_app_gauntlet_test.dart +132 -87
  19. package/dart/test/local_sync_inert_test.dart +10 -5
  20. package/dart/test/parent_signer_recovery_test.dart +1 -1
  21. package/dart/test/sealing_client_test.dart +1 -1
  22. package/dart/test/session_bound_test.dart +1 -1
  23. package/dart/test/test_harness_test.dart +2 -2
  24. package/dart/test/vo_read_typing_test.dart +1 -1
  25. package/dart/test/watch_after_offer_stall_test.dart +1 -1
  26. package/dart/test/webview_bridge_test.dart +274 -0
  27. package/dart_types/CHANGELOG.md +6 -0
  28. package/dart_types/lib/src/errors.dart +136 -30
  29. package/dart_types/lib/src/surfaced.dart +48 -2
  30. package/dart_types/lib/src/wire.dart +3 -1
  31. package/dart_types/pubspec.yaml +1 -1
  32. package/dart_types/test/surfaced_test.dart +19 -0
  33. package/package.json +1 -1
  34. package/src/activity.ts +43 -4
  35. package/src/aggregate.ts +132 -22
  36. package/src/attested_core.ts +1 -1
  37. package/src/authoring.ts +119 -6
  38. package/src/birth_meter.ts +4 -4
  39. package/src/birth_meter_ensure.ts +165 -29
  40. package/src/births.ts +53 -22
  41. package/src/build_package.ts +96 -20
  42. package/src/business_birth.ts +79 -16
  43. package/src/codegen_dart.ts +267 -37
  44. package/src/codegen_dart_client.ts +930 -87
  45. package/src/codegen_openapi.ts +28 -5
  46. package/src/codegen_proof.ts +107 -11
  47. package/src/codegen_ts.ts +961 -135
  48. package/src/compile_package_main.ts +172 -53
  49. package/src/conflicts.ts +252 -0
  50. package/src/describe_model.ts +448 -0
  51. package/src/directive.ts +5 -38
  52. package/src/drivers.ts +12 -4
  53. package/src/engine_entry.ts +4 -0
  54. package/src/evolve_preview.ts +72 -11
  55. package/src/fields.ts +201 -55
  56. package/src/framework/application_home.ts +103 -0
  57. package/src/framework/application_home_domain.ts +2 -0
  58. package/src/framework/attested_receipts.ts +2 -2
  59. package/src/framework/auth_provider_law.ts +51 -24
  60. package/src/framework/business_corrections.ts +170 -0
  61. package/src/framework/disclosure.ts +1 -1
  62. package/src/framework/domain_lifecycle.ts +250 -16
  63. package/src/framework/encryption_identity.ts +1 -1
  64. package/src/framework/encryption_scope.ts +21 -13
  65. package/src/framework/era_certification.ts +1 -1
  66. package/src/framework/framework_aggregates.ts +2 -0
  67. package/src/framework/genesis.ts +1 -1
  68. package/src/framework/home.ts +22 -9
  69. package/src/framework/identity.ts +5 -5
  70. package/src/framework/impure_capability.ts +2 -2
  71. package/src/framework/owned_custody.ts +561 -0
  72. package/src/framework/platform_admin.ts +1 -1
  73. package/src/framework/relations.ts +9 -1
  74. package/src/framework/repair.ts +1 -1
  75. package/src/framework/signing_identity.ts +2 -2
  76. package/src/framework/source_roster.ts +1 -1
  77. package/src/framework/workspaces.ts +17 -10
  78. package/src/generated_client_names.ts +39 -0
  79. package/src/index.ts +20 -15
  80. package/src/kernel_capabilities.ts +25 -1
  81. package/src/law_check.ts +4 -1
  82. package/src/manifest.ts +356 -63
  83. package/src/read_projection_manifest.ts +8 -0
  84. package/src/read_projection_namespace.ts +4 -0
  85. package/src/relation.ts +1 -1
  86. package/src/shape_compat.ts +61 -5
  87. package/src/stable_ids.ts +38 -6
  88. package/src/stable_ids_types.ts +30 -0
  89. package/src/surfaced_lint.ts +0 -0
  90. package/src/usd.ts +62 -11
  91. package/src/usd_layers.ts +54 -3
  92. package/src/value_object.ts +23 -19
  93. package/src/wire.ts +1 -0
  94. package/src/wire_encode.ts +48 -4
  95. package/src/workspace_routing.ts +57 -0
  96. package/src/workspace_sharding.ts +9 -9
  97. package/src/certified_read.ts +0 -313
@@ -30,7 +30,12 @@ import {
30
30
  captureBirthMeterReads,
31
31
  planBirthRecordOps,
32
32
  } from "./birth_meter.js";
33
- import type { AggregateHandle } from "./aggregate.js";
33
+ import {
34
+ OWNED_CUSTODY_PAYLOAD_FIELD,
35
+ OWNED_KEY_PAYLOAD_FIELD,
36
+ OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
37
+ type AggregateHandle,
38
+ } from "./aggregate.js";
34
39
  import { admitsNull, type Field, type EvidenceRefValue } from "./fields.js";
35
40
  import type { Directive, ReferentialMarker } from "./directive.js";
36
41
  import type { PlannedOp, FieldOp } from "./ops.js";
@@ -282,7 +287,40 @@ export function executeDirectiveToIntent<P>(
282
287
  `but was given handle '${agg.id}'.`,
283
288
  );
284
289
  }
285
- const parsed = directive.payloadSchema.parse(payload);
290
+ const rawPayload = payload as unknown;
291
+ const ownedKeySubject = typeof rawPayload === "object" && rawPayload !== null
292
+ ? (rawPayload as Record<string, unknown>)[OWNED_KEY_PAYLOAD_FIELD]
293
+ : undefined;
294
+ const ownedCustody = typeof rawPayload === "object" && rawPayload !== null
295
+ ? (rawPayload as Record<string, unknown>)[OWNED_CUSTODY_PAYLOAD_FIELD]
296
+ : undefined;
297
+ const preMintedRecordsRaw = typeof rawPayload === "object" && rawPayload !== null
298
+ ? (rawPayload as Record<string, unknown>)[OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD]
299
+ : undefined;
300
+ if (preMintedRecordsRaw !== undefined && !Array.isArray(preMintedRecordsRaw)) {
301
+ throw new Error("Nomos owned pre-minted record identities must be an array");
302
+ }
303
+ const preMintedRecords = Array.isArray(preMintedRecordsRaw)
304
+ ? preMintedRecordsRaw.map((value) => {
305
+ if (typeof value !== "object" || value === null) {
306
+ throw new Error("Nomos owned pre-minted record identity is malformed");
307
+ }
308
+ const type = (value as Record<string, unknown>).type;
309
+ const id = (value as Record<string, unknown>).id;
310
+ if (typeof type !== "string" || type.length === 0 || typeof id !== "string" || id.length === 0) {
311
+ throw new Error("Nomos owned pre-minted record identity must carry non-empty type and id strings");
312
+ }
313
+ return { type, id };
314
+ })
315
+ : [];
316
+ const businessPayload = typeof rawPayload === "object" && rawPayload !== null
317
+ ? Object.fromEntries(Object.entries(rawPayload as Record<string, unknown>).filter(
318
+ ([key]) => key !== OWNED_KEY_PAYLOAD_FIELD
319
+ && key !== OWNED_CUSTODY_PAYLOAD_FIELD
320
+ && key !== OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
321
+ ))
322
+ : rawPayload;
323
+ const parsed = directive.payloadSchema.parse(businessPayload);
286
324
  const hlc = ctx.clock();
287
325
  const marker = encodeRefMode(directive.marker);
288
326
  // THE NOMOS SHAPE: a plan emits events across aggregates via TWO library doors, merged here —
@@ -290,7 +328,13 @@ export function executeDirectiveToIntent<P>(
290
328
  // (2) the plan may ALSO return explicit `PlannedOp`s (the typed `set`/`addToSet`/`strike` surface).
291
329
  // Reset the sinks, run the plan, then merge authored ops (births/relations) ahead of the returned ops.
292
330
  // The plan may also READ (O(1) DSL queries via `read(...)`) — captured into the footprint below.
293
- __resetAuthoring();
331
+ __resetAuthoring(
332
+ ctx,
333
+ String(hlc.physical),
334
+ typeof ownedKeySubject === "string" ? ownedKeySubject : undefined,
335
+ typeof ownedCustody === "string" ? ownedCustody : null,
336
+ preMintedRecords,
337
+ );
294
338
  __resetReads();
295
339
  __resetBirths();
296
340
  const returned = directive.plan(parsed, ctx);
@@ -482,7 +526,7 @@ export function harvestPrincipalFlows(directive: Directive<unknown>): PrincipalF
482
526
  g.nomos = { ...(prior ?? {}), mint: (typeTag: string) => `${typeTag}_0192f00d-0000-7000-8000-000000000000` };
483
527
  }
484
528
  let ops: FieldOp[];
485
- __resetAuthoring();
529
+ __resetAuthoring(ctx, String(ctx.clock().physical));
486
530
  __resetReads();
487
531
  try {
488
532
  const returned = directive.plan(probe as never, ctx);
@@ -486,6 +486,63 @@ export function mintedCreateField(d: AnyDirective, agg: AggregateHandle): string
486
486
  return undefined;
487
487
  }
488
488
 
489
+ /** The record identity an encrypted owned directive targets. Generated clients need this before they
490
+ * seal a field so the ciphertext scope is `<qualified-type>:<id>#<owned-reader>`. This is derived by
491
+ * executing the real plan with sentinels, never by guessing that a field happens to be named `id`.
492
+ *
493
+ * - `payload`: the plan addresses the record from a public business payload field;
494
+ * - `literal`: the plan addresses one deterministic instance;
495
+ * - `premint`: `create(Aggregate)` mints internally, so the client must reserve a hidden typed id that
496
+ * the authoring surface consumes during the real/replay plan.
497
+ */
498
+ export type DirectiveRecordTarget =
499
+ | { readonly kind: "payload"; readonly field: string }
500
+ | { readonly kind: "literal"; readonly id: string }
501
+ | { readonly kind: "premint" };
502
+
503
+ export function deriveDirectiveRecordTarget(
504
+ d: AnyDirective,
505
+ agg: AggregateHandle,
506
+ ): DirectiveRecordTarget | undefined {
507
+ if (d.aggregateId !== agg.id) return undefined;
508
+ const schema = (d as unknown as { payloadSchema?: z.ZodTypeAny }).payloadSchema;
509
+ if (schema === undefined || zodKind(schema) !== "object") return undefined;
510
+ const sentinelOf = new Map<string, string>();
511
+ const fieldOfSentinel = new Map<string, string>();
512
+ for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
513
+ let f = raw as z.ZodTypeAny;
514
+ while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
515
+ if (zodKind(f) !== "string") continue;
516
+ const sentinel = `__NOMOS_RECORD_PROBE_${name}__`;
517
+ sentinelOf.set(name, sentinel);
518
+ fieldOfSentinel.set(sentinel, name);
519
+ }
520
+
521
+ const classify = (events: readonly TracedEvent[]): DirectiveRecordTarget | undefined => {
522
+ const targets = events.filter((event) => event.type === agg.id || (event.type === undefined && event.aggregate === agg.id));
523
+ if (targets.length !== 1) return undefined;
524
+ const target = targets[0]!.aggregate;
525
+ const payloadField = fieldOfSentinel.get(target);
526
+ if (payloadField !== undefined) return { kind: "payload", field: payloadField };
527
+ if (target.startsWith(`${agg.id}_00000000-0000-7000-8000-`)) return { kind: "premint" };
528
+ return { kind: "literal", id: target };
529
+ };
530
+
531
+ const traced = tracePlan(d, agg, sentinelOf);
532
+ const direct = traced === undefined ? undefined : classify(traced);
533
+ if (direct !== undefined) return direct;
534
+
535
+ // Plans that JSON.parse another string field may reject the all-sentinel probe. Retry one possible
536
+ // identity field at a time with every sibling string JSON-safe, matching the routing derivation.
537
+ for (const [name, sentinel] of sentinelOf) {
538
+ const one = tracePlan(d, agg, new Map([[name, sentinel]]), true);
539
+ if (one === undefined) continue;
540
+ const classified = classify(one);
541
+ if (classified?.kind === "payload" && classified.field === name) return classified;
542
+ }
543
+ return undefined;
544
+ }
545
+
489
546
  /** One generated-client mint instruction (the TS front-door, taxonomy packages only). */
490
547
  export interface ClientMintPlanEntry {
491
548
  /** The directive id. */
@@ -211,7 +211,7 @@ export const NomosShardAssignment = aggregate("NomosShardAssignment", {
211
211
  mapVersion: t.int().merge(Lww),
212
212
  status: t.string().merge(Lww), // active | moving | moved (slice 5 flips these)
213
213
  placedAt: t.string().merge(Lww),
214
- });
214
+ }).public();
215
215
 
216
216
  /** The home's CURRENT assignment row, keyed by home key. Top-level so the §6 move directives can
217
217
  * CAPTURED-READ it (P6 S3a — the map version is derived in law, not by the host). Also routed by
@@ -235,7 +235,7 @@ export const NomosShardIdentity = aggregate("NomosShardIdentity", {
235
235
  label: t.string().merge(Lww), // "s0"…, or "" = the coordinator
236
236
  coordinator: t.string().merge(Lww), // the coordinator workspace's name
237
237
  declaredAt: t.string().merge(Lww),
238
- });
238
+ }).public();
239
239
 
240
240
  /**
241
241
  * THE ASSIGNMENT RECEIPT (slice 3 — §3's receipt leg): the shard's own chain records
@@ -251,14 +251,14 @@ export const NomosHomeReceipt = aggregate("NomosHomeReceipt", {
251
251
  mapVersion: t.int().merge(Lww),
252
252
  status: t.string().merge(Lww), // active | moved (slice 5 — the tombstone IS a redirect: `shard` then names the target)
253
253
  receivedAt: t.string().merge(Lww),
254
- });
254
+ }).public();
255
255
 
256
256
  /** The OPEN-SHARD registry row (coordinator law-state; `s0…s{pool-1}` are implicit). */
257
257
  export const NomosShardRegistry = aggregate("NomosShardRegistry", {
258
258
  label: t.string().merge(Lww),
259
259
  status: t.string().merge(Lww), // open | retired (slice 5)
260
260
  openedAt: t.string().merge(Lww),
261
- });
261
+ }).public();
262
262
 
263
263
  /**
264
264
  * THE SPLIT POLICY singleton (coordinator law-state — ruling 5: 75% ships, the dial
@@ -274,7 +274,7 @@ export const NomosShardPolicy = aggregate("NomosShardPolicy", {
274
274
  // Absent ⇒ the host default (64); 0 ⇒ cadence off (explicit seals only).
275
275
  checkpointEveryDeltas: t.int().merge(Lww),
276
276
  setAt: t.string().merge(Lww),
277
- });
277
+ }).public();
278
278
 
279
279
  /**
280
280
  * THE CHECKPOINT SEAL (§5.5, slice 4 — ruling 1 made periodic): a committed
@@ -294,7 +294,7 @@ export const NomosCheckpointSeal = aggregate("NomosCheckpointSeal", {
294
294
  frontiers: t.string().merge(Lww), // canonical JSON: [{shard, frontier}] sorted by shard
295
295
  stateHash: t.string().merge(Lww), // sha256 over the coordinator's subtotal state at the seal
296
296
  sealedAt: t.string().merge(Lww),
297
- });
297
+ }).public();
298
298
 
299
299
  /** The canonical (shard-sorted) frontier-set bytes a seal folds — ONE shape on every peer. */
300
300
  export const canonicalSealFrontiers = (
@@ -330,7 +330,7 @@ export const NomosSummarySubtotal = aggregate("NomosSummarySubtotal", {
330
330
  value: t.int().merge(Lww),
331
331
  frontier: t.string().merge(Lww),
332
332
  empty: t.int().merge(Lww).optional(),
333
- });
333
+ }).public();
334
334
 
335
335
  /** Per-(shard) committed subtotal rows, keyed by shard. Top-level so the derived placement directive can
336
336
  * CAPTURED-READ each open shard's load (the `nomosShardRows` subtotal) to pick LEAST-LOADED IN THE PLAN
@@ -344,7 +344,7 @@ export const NomosSummaryFrontier = aggregate("NomosSummaryFrontier", {
344
344
  shard: t.string().merge(Lww),
345
345
  frontier: t.string().merge(Lww), // the shard main oid the subtotals are true at
346
346
  updatedAt: t.string().merge(Lww),
347
- });
347
+ }).public();
348
348
 
349
349
  /** THE RECORDED DEEP-VERIFY VERDICT (§5.5 — replay-on-suspicion, made operational). */
350
350
  export const NomosDeepVerify = aggregate("NomosDeepVerify", {
@@ -354,7 +354,7 @@ export const NomosDeepVerify = aggregate("NomosDeepVerify", {
354
354
  verdict: t.string().merge(Lww), // verified | mismatch:<…> | invalid:<…>
355
355
  detail: t.string().merge(Lww), // JSON: the per-(read,group) byte-compare table
356
356
  checkedAt: t.string().merge(Lww),
357
- });
357
+ }).public();
358
358
 
359
359
  export const nomosDeclareShardIdentity = directive("nomosDeclareShardIdentity")
360
360
  .ensures(NomosShardIdentity)
@@ -1,313 +0,0 @@
1
- // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
- // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
- // wasm32-wasip1 artifact {kernel · projection · embedded
4
- // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
- // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
- // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
-
8
- /**
9
- * **CertifiedRead — the DECLARED write-path read surface (survival Constraint 5;
10
- * `certified_read.md`).** A tenant directive's `decide()` may READ the current world to
11
- * decide whether a write is valid ("is this thing assigned? what's the balance? enough
12
- * stock?"). That read RESULT is a DECISION INPUT, so it must be a deterministic, captured,
13
- * byte-identical-across-peers fact ([[determinism-or-death]]). This module is the AUTHORING
14
- * surface a tenant uses to DECLARE such reads — the `reads:{ name: q.someProfiledRead(args) }`
15
- * shape from `certified_read.md` §"Tenant authoring".
16
- *
17
- * ## The two halves (mirroring `readmodel/src/certified_read.rs`)
18
- * 1. {@link sqlProfileV0Check} — the TS port of the Rust `SqlProfileV0` enforcer. The villain
19
- * is UNDER-SPECIFICATION, not SQLite: a declared write-path read MUST obey the restricted
20
- * profile so EVERY valid execution plan produces the SAME canonical result (explicit
21
- * `ORDER BY` ending in a UNIQUE tie-breaker on multi-row, NO clock/rng functions, BINARY
22
- * collation only, NO floats, explicit columns, no implicit rowid, a single pure SELECT). An
23
- * under-specified declared read is REFUSED at COMPILE (`certifiedRead(...).sql(...)` throws),
24
- * the SAME refusal the Rust `CertifiedReadDef::compile` raises — so a tenant cannot author a
25
- * non-deterministic write-path read.
26
- * 2. {@link certifiedRead} — the declaration builder. `certifiedRead("stockAvailable")
27
- * .sql("SELECT value_json FROM rows WHERE …", { multiRow:false })` yields a
28
- * {@link CertifiedReadDecl} the directive's `.readsCertified({ available: decl })` attaches.
29
- * The declared `query_id`s land in the canonical manifest (`manifest.ts`), so the
30
- * ONE write-path gate (`executor::admit` step 5.6) can DERIVE the expected declared-read set
31
- * and REFUSE any admit where a declared read produced no witness (the vacuous-pass close).
32
- *
33
- * THE BOUNDARY: a tenant declares the SQL recipe + the shape (single/multi-row + tie-breakers).
34
- * Nomos owns the determinism (the profile, the canonical encoding, the witness, the admission
35
- * re-run). The DSL author NEVER mints a witness or touches SQLite at decide time — the HOST
36
- * resolves each declared read against the pinned projection snapshot and records the witness
37
- * (the capture wire; the Rust `CertifiedReadPort::resolve_and_witness`).
38
- */
39
-
40
- /** The SQL-profile-v0 version pin — frozen alongside the Rust `SQL_PROFILE_VERSION`. */
41
- export const SQL_PROFILE_VERSION = "v0";
42
-
43
- /** The NON-DETERMINISTIC SQL functions BANNED on the write/decide path — the clock family +
44
- * the entropy family. Lower-cased (SQLite function names are case-insensitive). Byte-identical
45
- * to the Rust `BANNED_FUNCTIONS` so the TS compile refusal and the Rust runtime guard agree. */
46
- export const BANNED_FUNCTIONS = [
47
- "random",
48
- "randomblob",
49
- "datetime",
50
- "date",
51
- "time",
52
- "julianday",
53
- "unixepoch",
54
- "strftime",
55
- "current_timestamp",
56
- "current_time",
57
- "current_date",
58
- "localtime",
59
- "localtimestamp",
60
- ] as const;
61
-
62
- /** Why a declared write-path read was REFUSED by the SQL-profile-v0 enforcer — the same typed
63
- * cause set as the Rust `ProfileViolation`. Carried in the thrown {@link ProfileError}. */
64
- export type ProfileViolation =
65
- | { kind: "SelectStar" }
66
- | { kind: "MissingOrderBy" }
67
- | { kind: "NoUniqueTieBreaker" }
68
- | { kind: "BannedFunction"; name: string }
69
- | { kind: "NonBinaryCollation"; collation: string }
70
- | { kind: "FloatInDecision"; literal: string }
71
- | { kind: "ImplicitRowid" }
72
- | { kind: "NotASingleSelect" };
73
-
74
- /** A compile-time refusal of an under-specified declared read (the villain is
75
- * UNDER-SPECIFICATION). Thrown by {@link certifiedRead}'s `.sql(...)`. */
76
- export class ProfileError extends Error {
77
- constructor(public readonly violation: ProfileViolation) {
78
- super(`SQL-profile-v0 violation: ${describe(violation)}`);
79
- this.name = "ProfileError";
80
- }
81
- }
82
-
83
- function describe(v: ProfileViolation): string {
84
- switch (v.kind) {
85
- case "SelectStar":
86
- return "SELECT * is banned on the write path — list explicit columns";
87
- case "MissingOrderBy":
88
- return "a multi-row query MUST have an explicit ORDER BY (row order is UNDEFINED without one)";
89
- case "NoUniqueTieBreaker":
90
- return "ORDER BY MUST end in a UNIQUE deterministic tie-breaker (e.g. `…, aggregate_id`)";
91
- case "BannedFunction":
92
- return `non-deterministic function \`${v.name}\` is banned (the clock/rng family)`;
93
- case "NonBinaryCollation":
94
- return `COLLATE \`${v.collation}\` is banned — only BINARY/canonical collation is byte-stable`;
95
- case "FloatInDecision":
96
- return `float literal \`${v.literal}\` in a decision is banned — use integers / decimals-as-strings`;
97
- case "ImplicitRowid":
98
- return "implicit rowid is banned unless modeled";
99
- case "NotASingleSelect":
100
- return "the write-path read must be a SINGLE pure SELECT (no chained statements / write verb)";
101
- }
102
- }
103
-
104
- // ── the profile parser (lower-cased input only; a faithful TS port of the Rust enforcer) ──
105
-
106
- function collapseWs(s: string): string {
107
- return ` ${s.replace(/\s+/g, " ").trim()} `;
108
- }
109
-
110
- function containsWord(hay: string, word: string): boolean {
111
- if (word.length === 0) return false;
112
- const re = new RegExp(`(?<![A-Za-z0-9_])${escapeRe(word)}(?![A-Za-z0-9_])`);
113
- return re.test(hay);
114
- }
115
-
116
- function containsFunctionCall(hay: string, name: string): boolean {
117
- // `name` followed (with optional spaces) by `(`, and NOT preceded by a word char.
118
- const re = new RegExp(`(?<![A-Za-z0-9_])${escapeRe(name)}\\s*\\(`);
119
- return re.test(hay);
120
- }
121
-
122
- function escapeRe(s: string): string {
123
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124
- }
125
-
126
- function selectListOf(trimmed: string): string {
127
- const afterSelect = trimmed.startsWith("select ") ? trimmed.slice("select ".length) : trimmed;
128
- const idx = afterSelect.indexOf(" from ");
129
- return idx >= 0 ? afterSelect.slice(0, idx) : afterSelect;
130
- }
131
-
132
- function orderByClause(normalized: string): string | undefined {
133
- const idx = normalized.lastIndexOf(" order by ");
134
- if (idx < 0) return undefined;
135
- const after = normalized.slice(idx + " order by ".length);
136
- const lim = after.indexOf(" limit ");
137
- return (lim >= 0 ? after.slice(0, lim) : after).trim();
138
- }
139
-
140
- function collateOtherThanBinary(normalized: string): string | undefined {
141
- const re = /collate\s+([a-z0-9_]+)/g;
142
- let m: RegExpExecArray | null;
143
- while ((m = re.exec(normalized)) !== null) {
144
- const name = m[1]!;
145
- if (name !== "binary") return name;
146
- }
147
- return undefined;
148
- }
149
-
150
- function floatLiteral(normalized: string): string | undefined {
151
- // A numeric literal that is NOT a pure integer: a decimal point and/or scientific exponent,
152
- // at a token boundary, outside single-quoted strings. Mirrors the Rust `float_literal`.
153
- let inStr = false;
154
- const isIdent = (c: string) => /[A-Za-z0-9_]/.test(c);
155
- const n = normalized.length;
156
- let i = 0;
157
- while (i < n) {
158
- const ch = normalized[i]!;
159
- if (ch === "'") {
160
- inStr = !inStr;
161
- i += 1;
162
- continue;
163
- }
164
- if (inStr) {
165
- i += 1;
166
- continue;
167
- }
168
- const prev = i > 0 ? normalized[i - 1]! : "";
169
- const startsDigit = /[0-9]/.test(ch);
170
- const startsDot = ch === "." && i + 1 < n && /[0-9]/.test(normalized[i + 1]!);
171
- const boundary = i === 0 || (!isIdent(prev) && prev !== ".");
172
- if (boundary && (startsDigit || startsDot)) {
173
- const start = i;
174
- let isFloat = startsDot;
175
- while (i < n && /[0-9.]/.test(normalized[i]!)) {
176
- if (normalized[i] === ".") isFloat = true;
177
- i += 1;
178
- }
179
- if (i < n && (normalized[i] === "e" || normalized[i] === "E")) {
180
- let j = i + 1;
181
- if (j < n && (normalized[j] === "+" || normalized[j] === "-")) j += 1;
182
- if (j < n && /[0-9]/.test(normalized[j]!)) {
183
- isFloat = true;
184
- i = j;
185
- while (i < n && /[0-9]/.test(normalized[i]!)) i += 1;
186
- }
187
- }
188
- if (isFloat) return normalized.slice(start, i);
189
- continue;
190
- }
191
- i += 1;
192
- }
193
- return undefined;
194
- }
195
-
196
- /**
197
- * Check `sql` against SQL-profile-v0 — the TS port of `SqlProfileV0::check`. `multiRow` is
198
- * whether the query can return more than one row (a scalar COUNT/MAX is single-row, exempt from
199
- * the ORDER BY rule). `uniqueTieBreakers` are the declared unique deterministic key columns the
200
- * multi-row `ORDER BY` must end in. Returns the FIRST {@link ProfileViolation}, or `undefined`
201
- * when the query obeys EVERY rule.
202
- */
203
- export function sqlProfileV0Check(
204
- sql: string,
205
- multiRow: boolean,
206
- uniqueTieBreakers: string[],
207
- ): ProfileViolation | undefined {
208
- const lower = sql.toLowerCase();
209
- const normalized = collapseWs(lower);
210
-
211
- // single pure SELECT (no chained statements / write verbs)
212
- const trimmed = normalized.replace(/;+$/, "").trim();
213
- if (trimmed.includes(";")) return { kind: "NotASingleSelect" };
214
- if (!trimmed.startsWith("select ") && trimmed !== "select") return { kind: "NotASingleSelect" };
215
- for (const verb of [
216
- " insert ", " update ", " delete ", " drop ", " create ", " alter ", " attach ",
217
- " pragma ", " replace ", " reindex ", " vacuum ",
218
- ]) {
219
- if (normalized.includes(verb)) return { kind: "NotASingleSelect" };
220
- }
221
-
222
- // explicit columns (no SELECT *), allowing the one `count(*)`
223
- const selectList = selectListOf(trimmed).replaceAll("count(*)", "count_star");
224
- if (selectList.includes("*")) return { kind: "SelectStar" };
225
-
226
- // banned non-deterministic functions (clock/rng family), anywhere
227
- for (const name of BANNED_FUNCTIONS) {
228
- if (containsFunctionCall(normalized, name)) return { kind: "BannedFunction", name };
229
- if (name.startsWith("current_") && containsWord(normalized, name)) {
230
- return { kind: "BannedFunction", name };
231
- }
232
- }
233
-
234
- // BINARY/canonical collation only
235
- const coll = collateOtherThanBinary(normalized);
236
- if (coll !== undefined) return { kind: "NonBinaryCollation", collation: coll };
237
-
238
- // no implicit rowid (unless modeled as a column)
239
- for (const kw of ["rowid", "_rowid_", "oid"]) {
240
- if (containsWord(normalized, kw)) return { kind: "ImplicitRowid" };
241
- }
242
-
243
- // no floats in decision-critical results
244
- const lit = floatLiteral(normalized);
245
- if (lit !== undefined) return { kind: "FloatInDecision", literal: lit };
246
-
247
- // ORDER BY with a unique tie-breaker (multi-row only)
248
- if (multiRow) {
249
- const orderBy = orderByClause(normalized);
250
- if (orderBy === undefined) return { kind: "MissingOrderBy" };
251
- let lastTerm = orderBy.split(",").pop()!.trim();
252
- const collIdx = lastTerm.indexOf(" collate ");
253
- if (collIdx >= 0) lastTerm = lastTerm.slice(0, collIdx).trim();
254
- lastTerm = lastTerm.replace(/ asc$/, "").replace(/ desc$/, "").trim();
255
- const bare = lastTerm.includes(".") ? lastTerm.split(".").pop()! : lastTerm;
256
- const ok = uniqueTieBreakers.some((t) => t === lastTerm || t === bare);
257
- if (!ok) return { kind: "NoUniqueTieBreaker" };
258
- }
259
-
260
- return undefined;
261
- }
262
-
263
- /**
264
- * A FINISHED, profile-conforming CertifiedRead declaration — the write-path read API surface
265
- * (the DSL analogue of the Rust `CertifiedReadDef`). Construction (`certifiedRead(id).sql(...)`)
266
- * is the COMPILE-TIME refusal: a profile-violating SQL throws {@link ProfileError}, so a
267
- * `CertifiedReadDecl` value is a profile-conformance WITNESS by construction. The `queryId` is
268
- * which question; the `sql` is the validated recipe; `multiRow` + `uniqueTieBreakers` are the
269
- * shape the profile + the admission re-run pin.
270
- */
271
- export interface CertifiedReadDecl {
272
- readonly queryId: string;
273
- readonly sql: string;
274
- readonly multiRow: boolean;
275
- readonly uniqueTieBreakers: string[];
276
- }
277
-
278
- /** Options for {@link CertifiedReadBuilder.sql}. */
279
- export interface CertifiedReadOpts {
280
- /** Whether the query can return more than one row (drives the ORDER BY requirement).
281
- * Defaults to `false` (a single-row scalar read, e.g. a COUNT/MAX — exempt from ORDER BY). */
282
- readonly multiRow?: boolean;
283
- /** The declared unique deterministic tie-breaker column(s) the multi-row ORDER BY must end in
284
- * (e.g. `["aggregate_id"]`). Required for a `multiRow` query; ignored for single-row. */
285
- readonly uniqueTieBreakers?: string[];
286
- }
287
-
288
- /** Stage 2: attach the profiled SQL recipe, producing the finished {@link CertifiedReadDecl}. */
289
- class CertifiedReadBuilder<Id extends string> {
290
- constructor(private readonly id: Id) {}
291
- /**
292
- * Declare the read's SQL recipe. COMPILE-TIME refusal: the SQL is validated against
293
- * SQL-profile-v0 here ({@link sqlProfileV0Check}) and THROWS {@link ProfileError} on any
294
- * under-specification — the SAME refusal the Rust `CertifiedReadDef::compile` raises. So a
295
- * non-conforming declared read can never become a {@link CertifiedReadDecl}.
296
- */
297
- sql(text: string, opts: CertifiedReadOpts = {}): CertifiedReadDecl {
298
- const multiRow = opts.multiRow ?? false;
299
- const uniqueTieBreakers = [...(opts.uniqueTieBreakers ?? [])];
300
- const violation = sqlProfileV0Check(text, multiRow, uniqueTieBreakers);
301
- if (violation !== undefined) throw new ProfileError(violation);
302
- return { queryId: this.id, sql: text, multiRow, uniqueTieBreakers };
303
- }
304
- }
305
-
306
- /**
307
- * Begin a CertifiedRead declaration: `certifiedRead("stockAvailable").sql("SELECT … FROM …",
308
- * { multiRow:false })`. The id is the `query_id` frozen into the witness + the manifest's
309
- * declared-read set; `.sql(...)` validates the recipe and produces the finished decl.
310
- */
311
- export function certifiedRead<const Id extends string>(id: Id): CertifiedReadBuilder<Id> {
312
- return new CertifiedReadBuilder(id);
313
- }