@githolon/dsl 0.70.2 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart/CHANGELOG.md +13 -0
- package/dart/lib/nomos_client.dart +4 -0
- package/dart/lib/src/session_bound.dart +309 -0
- package/dart/lib/src/test_harness.dart +136 -4
- package/dart/lib/src/webview_bridge.dart +173 -27
- package/dart/pubspec.yaml +1 -1
- package/dart/test/cluster_attested_test.dart +0 -1
- package/dart/test/session_bound_test.dart +255 -0
- package/dart_types/CHANGELOG.md +8 -0
- package/dart_types/lib/nomos_types.dart +6 -0
- package/dart_types/lib/src/principal.dart +56 -0
- package/dart_types/lib/src/surfaced.dart +208 -0
- package/dart_types/pubspec.yaml +1 -1
- package/dart_types/test/surfaced_test.dart +69 -0
- package/package.json +1 -1
- package/src/aggregate.ts +64 -7
- package/src/build_package.ts +22 -0
- package/src/codegen_dart.ts +155 -31
- package/src/codegen_dart_client.ts +274 -15
- package/src/codegen_openapi.ts +20 -0
- package/src/codegen_proof.ts +5 -1
- package/src/codegen_ts.ts +166 -1
- package/src/compile_package_main.ts +126 -9
- package/src/directive.ts +40 -0
- package/src/drivers.ts +63 -2
- package/src/idof_lint.ts +144 -0
- package/src/index.ts +1 -0
- package/src/surfaced_lint.ts +0 -0
- package/src/usd_layers.ts +5 -0
- package/src/wire.ts +4 -1
package/src/codegen_ts.ts
CHANGED
|
@@ -470,7 +470,16 @@ function tsTypeOfPayload(
|
|
|
470
470
|
// ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
|
|
471
471
|
|
|
472
472
|
function tsTypeOfField(field: Field): string {
|
|
473
|
-
|
|
473
|
+
let base = tsBaseTypeOfField(field);
|
|
474
|
+
// SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the FIRST driver that
|
|
475
|
+
// changes a field's generated type. A `.merge(Surfaced(...))` field reads as the plain scalar
|
|
476
|
+
// once resolved, or the `{__surfaced:{pending:[...]}}` envelope while genuinely concurrent
|
|
477
|
+
// writes await a human pick — typed as the discriminated union `SurfacedValue<T>` (emitted
|
|
478
|
+
// once per file that needs it). The client does NO transformation; this types the exact
|
|
479
|
+
// read-collapse JSON.
|
|
480
|
+
if ((field.driver as { kind?: string } | undefined)?.kind === "Surfaced") {
|
|
481
|
+
base = `SurfacedValue<${base}>`;
|
|
482
|
+
}
|
|
474
483
|
// NULL SEMANTICS (null_semantics.md): a `nullable`/`explicit-empty` field ADMITS a domain `null`,
|
|
475
484
|
// so its read-model type carries the `| null` arm (distinct from the `?` partial-fold absence a
|
|
476
485
|
// non-null-admitting field gets). A `forbid` json/VO field NEVER gets the null arm.
|
|
@@ -787,6 +796,20 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
787
796
|
);
|
|
788
797
|
const encPresent = encMap !== null;
|
|
789
798
|
const encConst = encryptionConstName(opts.packageName);
|
|
799
|
+
// A package declaring ≥1 `.births()` directive emits the branded identity wrappers (below) its typed
|
|
800
|
+
// birth methods take for `actor`/`authorSecret`. Births-free packages emit nothing (byte-identical).
|
|
801
|
+
const hasBirths = modules.some((m) =>
|
|
802
|
+
(m.directives as Directive<unknown>[]).some((d) => (d as { birthsChild?: boolean })?.birthsChild === true),
|
|
803
|
+
);
|
|
804
|
+
// SURFACED MERGE CONFLICTS: a package declaring ≥1 `.merge(Surfaced(...))` field emits the
|
|
805
|
+
// `SurfacedValue<T>` union + guard (below). Surfaced-free packages emit nothing (byte-identical).
|
|
806
|
+
const hasSurfaced = modules.some((m) =>
|
|
807
|
+
m.aggregates.some((a) =>
|
|
808
|
+
Object.values(a.fields as Record<string, Field>).some(
|
|
809
|
+
(f) => (f.driver as { kind?: string } | undefined)?.kind === "Surfaced",
|
|
810
|
+
),
|
|
811
|
+
),
|
|
812
|
+
);
|
|
790
813
|
// ACTIVITY FEED (architecture/activity_descriptors.md): the package's declared `.activity(...)` descriptors.
|
|
791
814
|
// Empty ⇒ NOTHING activity-related emits (byte-identical to a pre-activity compile).
|
|
792
815
|
const activityEntries = collectActivityDescriptors(modules);
|
|
@@ -945,6 +968,45 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
945
968
|
` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
|
|
946
969
|
`export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
|
|
947
970
|
``,
|
|
971
|
+
// SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the read-collapse union for a
|
|
972
|
+
// `.merge(Surfaced(...))` field — the plain scalar once resolved, or the pending envelope while
|
|
973
|
+
// genuinely concurrent writes await a human pick. Emitted ONLY when the package declares one.
|
|
974
|
+
...(hasSurfaced
|
|
975
|
+
? [
|
|
976
|
+
`/** One competing observation on a PENDING Surfaced field. \`author\` is the writer principal —`,
|
|
977
|
+
` * nullable (an observation folded where no intent context existed carries none). The author`,
|
|
978
|
+
` * JSON key is pinned in ONE place: this property name (kernel b215430d — exactly "author"). */`,
|
|
979
|
+
`export interface SurfacedObservation<T> {`,
|
|
980
|
+
` value: T;`,
|
|
981
|
+
` occurredAt: { physical: number; logical: number; replica: number };`,
|
|
982
|
+
` author?: string | null;`,
|
|
983
|
+
`}`,
|
|
984
|
+
`/** The PENDING read-collapse of a \`.merge(Surfaced(...))\` field: the kernel retained BOTH sides`,
|
|
985
|
+
` * of a genuinely concurrent write (ALWAYS 2+ observations — a single-observation set collapses`,
|
|
986
|
+
` * to the plain scalar; bounded, cap 8) instead of silently picking a winner. Resolve`,
|
|
987
|
+
` * it with an ORDINARY next write (a \`.mutates()\` directive that set()s the field) — the field`,
|
|
988
|
+
` * then reads as the plain scalar again, indistinguishable from any other field. */`,
|
|
989
|
+
`export interface SurfacedPending<T> { __surfaced: { pending: SurfacedObservation<T>[] } }`,
|
|
990
|
+
`/** A Surfaced field's read value: the resolved scalar, or the pending envelope under conflict. */`,
|
|
991
|
+
`export type SurfacedValue<T> = T | SurfacedPending<T>;`,
|
|
992
|
+
`/** True iff \`v\` is the pending-conflict envelope (narrows to [SurfacedPending]). */`,
|
|
993
|
+
`export function isSurfacedPending<T>(v: SurfacedValue<T> | null | undefined): v is SurfacedPending<T> {`,
|
|
994
|
+
` return typeof v === "object" && v !== null && "__surfaced" in (v as Record<string, unknown>);`,
|
|
995
|
+
`}`,
|
|
996
|
+
`/** ONE row of the workspace-wide \`pendingConflicts()\` read (reserved id \`nomosPendingConflicts\`):`,
|
|
997
|
+
` * a (aggregate, field) whose folded state awaits a human pick. MAINTAINED like counts/sums`,
|
|
998
|
+
` * (fold-time, O(pending) to read) and DERIVED, NOT DRAINED — a resolution write folding in removes`,
|
|
999
|
+
` * the row by derivation. The workspace-wide surface is HETEROGENEOUS, so each observation's`,
|
|
1000
|
+
` * \`value\` is \`unknown\` here — the per-field typed seam stays on the read models (\`SurfacedValue<T>\`). */`,
|
|
1001
|
+
`export interface PendingConflictRow {`,
|
|
1002
|
+
` aggregateType: string;`,
|
|
1003
|
+
` aggregateId: string;`,
|
|
1004
|
+
` field: string;`,
|
|
1005
|
+
` observations: SurfacedObservation<unknown>[];`,
|
|
1006
|
+
`}`,
|
|
1007
|
+
``,
|
|
1008
|
+
]
|
|
1009
|
+
: []),
|
|
948
1010
|
`/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
|
|
949
1011
|
`export interface NomosHolon {`,
|
|
950
1012
|
` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
|
|
@@ -953,6 +1015,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
953
1015
|
` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string | { head: string; created?: { type: string; id: string }[]; workspace?: string }>;`,
|
|
954
1016
|
` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
|
|
955
1017
|
` offer(intentBytes: string | Uint8Array): Promise<string>;`,
|
|
1018
|
+
...(hasBirths
|
|
1019
|
+
? [
|
|
1020
|
+
` /** BIRTH A CHILD WORKSPACE (a signed CROSS-WORKSPACE governance offer to \`parent\`) — OPTIONAL:`,
|
|
1021
|
+
` * @githolon/client's connect() attaches it. The generated \`.births()\` methods call this, binding`,
|
|
1022
|
+
` * the declaring domain/directiveId/law hash so a frontend dev never hand-writes those raw strings. */`,
|
|
1023
|
+
` createWorkspace?(opts: { domain: string; directiveId: string; domainHash?: string; parent: string; actor: string; authorSecret?: string; payload?: unknown; name?: string; authToken?: string }): Promise<{ ok?: boolean; status?: number; body?: unknown }>;`,
|
|
1024
|
+
]
|
|
1025
|
+
: []),
|
|
956
1026
|
` /** The workspace this holon is bound to — the \`workspace\` of every [NomosRef] this client composes. */`,
|
|
957
1027
|
` workspace: string;`,
|
|
958
1028
|
...(encPresent
|
|
@@ -977,6 +1047,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
977
1047
|
` queryById(aggregateId: string): Promise<unknown>;`,
|
|
978
1048
|
]),
|
|
979
1049
|
` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
|
|
1050
|
+
...(hasSurfaced
|
|
1051
|
+
? [
|
|
1052
|
+
` /** The workspace-wide pending Surfaced-conflict read (reserved id \`nomosPendingConflicts\`).`,
|
|
1053
|
+
` * OPTIONAL member: structural binding tolerates a pre-Surfaced holon (the accessor throws at call). */`,
|
|
1054
|
+
` pendingConflicts?(): Promise<PendingConflictRow[]>;`,
|
|
1055
|
+
]
|
|
1056
|
+
: []),
|
|
980
1057
|
` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
|
|
981
1058
|
` count(countId: string, groupKey?: string): Promise<number>;`,
|
|
982
1059
|
` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query().`,
|
|
@@ -1046,6 +1123,25 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1046
1123
|
``,
|
|
1047
1124
|
);
|
|
1048
1125
|
|
|
1126
|
+
// BRANDED IDENTITY WRAPPERS for the typed cross-workspace birth methods: `actor` (NomosPrincipal) and
|
|
1127
|
+
// `authorSecret` (NomosDeviceSecret) are distinct branded strings, so a caller can't transpose the two
|
|
1128
|
+
// (a bare-string swap would compile and silently misfire). Emitted iff the package declares `.births()`.
|
|
1129
|
+
if (hasBirths) {
|
|
1130
|
+
out.push(
|
|
1131
|
+
`/** A verified principal identity (\`user:<uid>\` on the wire) — construct with [nomosPrincipal]. A distinct`,
|
|
1132
|
+
` * branded type from [NomosDeviceSecret] so the two birth-method params can never be transposed. */`,
|
|
1133
|
+
`export type NomosPrincipal = string & { readonly __nomosPrincipal: unique symbol };`,
|
|
1134
|
+
`/** A device signing secret (base64) — construct with [nomosDeviceSecret]. Distinct from [NomosPrincipal]. */`,
|
|
1135
|
+
`export type NomosDeviceSecret = string & { readonly __nomosDeviceSecret: unique symbol };`,
|
|
1136
|
+
`/** Brand a principal: a bare uid (\`ada\`) or a wire string (\`user:ada\`) → \`user:ada\`. */`,
|
|
1137
|
+
`export const nomosPrincipal = (uidOrWire: string): NomosPrincipal =>`,
|
|
1138
|
+
` (uidOrWire.startsWith("user:") ? uidOrWire : \`user:\${uidOrWire}\`) as NomosPrincipal;`,
|
|
1139
|
+
`/** Brand a device signing secret (base64). */`,
|
|
1140
|
+
`export const nomosDeviceSecret = (base64: string): NomosDeviceSecret => base64 as NomosDeviceSecret;`,
|
|
1141
|
+
``,
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1049
1145
|
if (encPresent) {
|
|
1050
1146
|
out.push(
|
|
1051
1147
|
`// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
|
|
@@ -1686,10 +1782,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1686
1782
|
for (const dir of Object.values(dm.directives)) for (const f of Object.values(dir)) s.add(f.scope);
|
|
1687
1783
|
return [...s].sort();
|
|
1688
1784
|
})();
|
|
1785
|
+
// directiveId → its emitted payload TYPE name (the surfaced `resolve…` helpers reference it).
|
|
1786
|
+
const dirPayloadNames = new Map<string, string>();
|
|
1689
1787
|
for (const d of dirs) {
|
|
1690
1788
|
let payloadName = `${cap(d.id)}Payload`;
|
|
1691
1789
|
if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
|
|
1692
1790
|
usedPayloadNames.add(payloadName);
|
|
1791
|
+
dirPayloadNames.set(d.id, payloadName);
|
|
1693
1792
|
const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
|
|
1694
1793
|
const stamps = autoStampFields(schema);
|
|
1695
1794
|
const mint = mintPlan?.get(d.id);
|
|
@@ -1723,6 +1822,27 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1723
1822
|
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
|
|
1724
1823
|
``,
|
|
1725
1824
|
);
|
|
1825
|
+
// A `.births()` directive is NOT a same-workspace write — it is a signed CROSS-WORKSPACE governance
|
|
1826
|
+
// offer to a PARENT. It gets a typed `holon.createWorkspace(...)` wrapper (declaring domain +
|
|
1827
|
+
// directiveId + baked domainHash bound at codegen time), NOT an `offerDirective`, so a frontend dev
|
|
1828
|
+
// never hand-writes the four raw strings this replaces (the co2 production bug).
|
|
1829
|
+
if ((d as { birthsChild?: boolean }).birthsChild === true) {
|
|
1830
|
+
const birthPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : "payload";
|
|
1831
|
+
const gateBirthDoc = gate ? ` GATED: needs \`actor\` (\`${gate.relation}\`).` : "";
|
|
1832
|
+
methods.push(
|
|
1833
|
+
` /** Births a CHILD WORKSPACE (named by \`${payloadName}\`) under \`parent\`, via a SIGNED cross-workspace`,
|
|
1834
|
+
` * governance offer to the parent's \`${domain}/${d.id}\` law (POST /v2/workspaces/<parent>/${d.id}) — NOT a`,
|
|
1835
|
+
` * same-workspace write, hence \`parent\`/\`actor\` (the requesting principal, e.g. \`user:<uid>\`) unlike the`,
|
|
1836
|
+
` * other methods. Declaring domain, directiveId, and law hash are baked in; returns the parent gateway's`,
|
|
1837
|
+
` * verdict. \`authorSecret\` defaults to the holon's device key.${gateBirthDoc} */`,
|
|
1838
|
+
` ${d.id}: (opts: { parent: string; actor: NomosPrincipal; authorSecret?: NomosDeviceSecret; payload: ${payloadName}; authToken?: string }): Promise<{ ok?: boolean; status?: number; body?: unknown }> => {`,
|
|
1839
|
+
...(gateGuard ? [` if (!opts.actor || !opts.actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate!.relation)});`] : []),
|
|
1840
|
+
` const _payload = opts.payload;`,
|
|
1841
|
+
` return (holon.createWorkspace ?? (() => { throw new Error("this holon predates cross-workspace births — upgrade @githolon/client"); }))({ domain: ${JSON.stringify(domain)}, directiveId: ${JSON.stringify(d.id)}, domainHash, parent: opts.parent, actor: opts.actor, authorSecret: opts.authorSecret, payload: ${birthPayload.replace("payload", "_payload")}, authToken: opts.authToken });`,
|
|
1842
|
+
` },`,
|
|
1843
|
+
);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1726
1846
|
// THE ONE WRITE OUTCOME (architecture/nomos_ref.md): EVERY directive method returns Promise<NomosOutcome>
|
|
1727
1847
|
// — `created` (typed refs of all minted aggregates, from returnCreated) + `head`, plus `target` for the
|
|
1728
1848
|
// front-door-mint lane (the one statically-known addressed instance). One async block covers every case.
|
|
@@ -1822,6 +1942,51 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1822
1942
|
: ` ${lcFirst(camel(s.id))}: () => holon.sum(${JSON.stringify(s.id)}),`,
|
|
1823
1943
|
);
|
|
1824
1944
|
}
|
|
1945
|
+
|
|
1946
|
+
// ── SURFACED CONFLICT DX (surfaced_merge_conflict.md): the workspace-wide pendingConflicts
|
|
1947
|
+
// accessor + one typed `resolve…` helper per designated `.resolves()` directive. Emitted iff
|
|
1948
|
+
// this domain declares a Surfaced field (byte-identical otherwise — the era rule).
|
|
1949
|
+
const resolverBindings = (mod as { surfacedResolvers?: import("./surfaced_lint.js").SurfacedResolverBinding[] }).surfacedResolvers ?? [];
|
|
1950
|
+
if (resolverBindings.length > 0) {
|
|
1951
|
+
methods.push(
|
|
1952
|
+
` /** PENDING SURFACED CONFLICTS across the workspace — the maintained "decisions needed" read`,
|
|
1953
|
+
` * (reserved id \`nomosPendingConflicts\`; derived, not drained: a resolution write removes its`,
|
|
1954
|
+
` * row by derivation). Pair each row with its typed \`resolve…\` helper below. */`,
|
|
1955
|
+
` pendingConflicts: () => {`,
|
|
1956
|
+
` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
|
|
1957
|
+
` return holon.pendingConflicts();`,
|
|
1958
|
+
` },`,
|
|
1959
|
+
` /** Watch the pending Surfaced conflicts (local reactive read; returns stop()). */`,
|
|
1960
|
+
` watchPendingConflicts: (cb: (rows: PendingConflictRow[]) => void, opts?: { intervalMs?: number }) => {`,
|
|
1961
|
+
` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
|
|
1962
|
+
` let stopped = false;`,
|
|
1963
|
+
` let last = "";`,
|
|
1964
|
+
` const tick = async () => { try { const rows = await holon.pendingConflicts!(); const j = JSON.stringify(rows); if (!stopped && j !== last) { last = j; cb(rows); } } catch { /* projection catching up */ } };`,
|
|
1965
|
+
` void tick();`,
|
|
1966
|
+
` const t = setInterval(tick, opts?.intervalMs ?? 1000);`,
|
|
1967
|
+
` return () => { stopped = true; clearInterval(t); };`,
|
|
1968
|
+
` },`,
|
|
1969
|
+
);
|
|
1970
|
+
for (const b of resolverBindings) {
|
|
1971
|
+
if (b.valuePayloadField === undefined) continue; // probe couldn't bind — dispatch the directive directly.
|
|
1972
|
+
const payloadName = dirPayloadNames.get(b.directiveId);
|
|
1973
|
+
if (payloadName === undefined) continue;
|
|
1974
|
+
const helper = `resolve${cap(camel(b.aggregateId))}${cap(camel(b.field))}`;
|
|
1975
|
+
const idField = b.idPayloadField;
|
|
1976
|
+
const bound = [b.valuePayloadField, ...(idField !== undefined ? [idField] : [])];
|
|
1977
|
+
const restType = `Omit<${payloadName}, ${bound.map((f) => JSON.stringify(f)).join(" | ")}>`;
|
|
1978
|
+
const conflictParam = idField !== undefined ? `conflict: string | PendingConflictRow, ` : ``;
|
|
1979
|
+
const payloadExpr = `{ ...(rest ?? {}), ${idField !== undefined ? `${JSON.stringify(idField)}: typeof conflict === "string" ? conflict : conflict.aggregateId, ` : ``}${JSON.stringify(b.valuePayloadField)}: chosen } as ${payloadName}`;
|
|
1980
|
+
methods.push(
|
|
1981
|
+
` /** RESOLVE a pending conflict on \`${b.aggregateId}.${b.field}\` by dispatching the DESIGNATED`,
|
|
1982
|
+
` * resolver \`${b.directiveId}\` with the chosen value${idField !== undefined ? ` (the conflict row — or the bare aggregate id — names the instance)` : ""}.`,
|
|
1983
|
+
` * ESCAPE HATCH: any gated directive that sets the field still resolves the conflict in the`,
|
|
1984
|
+
` * kernel sense; \`.resolves()\` only designates which one THIS typed helper dispatches. */`,
|
|
1985
|
+
` ${helper}: (${conflictParam}chosen: ${payloadName}[${JSON.stringify(b.valuePayloadField)}], rest?: ${restType}): Promise<NomosOutcome> =>`,
|
|
1986
|
+
` _outcome(holon.workspace, holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(b.directiveId)}, ${payloadExpr}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>),`,
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1825
1990
|
for (const agg of mod.aggregates as AggregateHandle[]) {
|
|
1826
1991
|
const aScopes = readScopesFor(agg.id);
|
|
1827
1992
|
methods.push(
|
|
@@ -225,6 +225,30 @@ interface PackageConfig {
|
|
|
225
225
|
readonly types: string;
|
|
226
226
|
readonly client: string;
|
|
227
227
|
readonly test?: string;
|
|
228
|
+
/**
|
|
229
|
+
* MIGRATION-ONLY COMPAT PACKAGE (architecture/dart_naming.md). When set, `dart.names` alias
|
|
230
|
+
* `typedef`s are routed HERE (a standalone `<compat>` pub package of pure typedefs over the
|
|
231
|
+
* real `types` package) INSTEAD of the types barrel — a clearly-labeled, deletable-when-done
|
|
232
|
+
* shim so callers can migrate to the real names at their own pace. Its `pubspec.yaml` is
|
|
233
|
+
* scaffolded only-if-missing (tenant-owned versioning) and depends on the types package. When
|
|
234
|
+
* UNSET, `dart.names` land in the types barrel exactly as before (fully backward compatible).
|
|
235
|
+
*/
|
|
236
|
+
readonly compat?: string;
|
|
237
|
+
/**
|
|
238
|
+
* BARREL PROMOTION POLICY (Jack's namespaces-not-prefixes directive, architecture/dart_naming.md).
|
|
239
|
+
* Each generated per-domain `.dart` file is ALREADY its own Dart library — `import
|
|
240
|
+
* 'package:<types>/src/generated/estate.dart' as estate;` then `estate.Site` is real, working
|
|
241
|
+
* namespacing regardless of this flag. This ONLY governs whether the top-level package barrel
|
|
242
|
+
* ALSO flat-re-exports every domain's classes into one unqualified scope:
|
|
243
|
+
* • `'all'` (DEFAULT) — flat-export every domain file (today's ergonomics; the single-domain
|
|
244
|
+
* common case is unaffected). Byte-identical to before this flag existed.
|
|
245
|
+
* • `'explicit'` — for MULTI-domain packages: the barrel re-exports only the SHARED core
|
|
246
|
+
* (framework/contracts/activity + nomos_types), NOT the per-domain aggregate/read-model
|
|
247
|
+
* classes, so two domains that both name a `Site` never silently share a flat scope —
|
|
248
|
+
* consumers disambiguate with `as`-prefixed per-domain imports. A SINGLE-domain package is
|
|
249
|
+
* unaffected (no collision risk ⇒ the lone domain stays flat-exported, byte-identical).
|
|
250
|
+
*/
|
|
251
|
+
readonly barrelExports?: "all" | "explicit";
|
|
228
252
|
};
|
|
229
253
|
/**
|
|
230
254
|
* PUBLIC ALIASES for generated type names (`{ "EstateEstateCustomFieldDefinitionFieldType":
|
|
@@ -433,16 +457,22 @@ const DART_SUPPORT_CORE_SRC = [
|
|
|
433
457
|
"foreign_ref.dart",
|
|
434
458
|
"activity.dart",
|
|
435
459
|
"nomos_ref.dart",
|
|
460
|
+
// Branded identity wrappers (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace birth
|
|
461
|
+
// methods type `actor`/`authorSecret` as these (not bare strings), so both must vendor. Pure data.
|
|
462
|
+
"principal.dart",
|
|
436
463
|
// The custody-lifecycle types (NomosCustodyBreak/NomosCustodyStatus/CustodyBreakPolicy) — the generated
|
|
437
464
|
// client + the vendored bridge reference them through the barrel, so they must vendor. Pure data.
|
|
438
465
|
"custody.dart",
|
|
466
|
+
// SURFACED MERGE CONFLICTS: SurfacedValue<T>/SurfacedObservation<T> — a generated read model with a
|
|
467
|
+
// `.merge(Surfaced(...))` field references them through the barrel, so they must vendor. Pure data.
|
|
468
|
+
"surfaced.dart",
|
|
439
469
|
] as const;
|
|
440
470
|
// The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
|
|
441
471
|
// bridge the generated `<name>.client.dart` drives, and the provider surface. Their
|
|
442
472
|
// `package:nomos_types/...` imports are rewritten to the local vendored shim (see emitDart).
|
|
443
473
|
// (`home.dart` is NOT vendored — the home-onboarding runtime drags `package:crypto`; nothing the
|
|
444
474
|
// codegen emits references it.)
|
|
445
|
-
const DART_SUPPORT_RUNTIME_SRC = ["provider.dart", "webview_bridge.dart"] as const;
|
|
475
|
+
const DART_SUPPORT_RUNTIME_SRC = ["provider.dart", "webview_bridge.dart", "session_bound.dart"] as const;
|
|
446
476
|
const DART_SUPPORT_SRC = [...DART_SUPPORT_CORE_SRC, ...DART_SUPPORT_RUNTIME_SRC] as const;
|
|
447
477
|
/** The local shim replacing `package:nomos_types/nomos_types.dart` inside vendored runtime files. */
|
|
448
478
|
const VENDORED_TYPES_SHIM = "nomos_types.dart";
|
|
@@ -869,7 +899,9 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
|
|
|
869
899
|
* Caret on 0.x is EXACT-MINOR (`^0.45.0` = `<0.46.0`), so these must track the CURRENT release, not
|
|
870
900
|
* just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
|
|
871
901
|
// 0.1.4: custody-lifecycle types (CustodyBreakPolicy et al).
|
|
872
|
-
|
|
902
|
+
// 0.2.0: branded birth identities (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace
|
|
903
|
+
// `.births()` methods type actor/authorSecret as these, so a layered package needs nomos_types 0.2.0.
|
|
904
|
+
const NOMOS_TYPES_DEP = "^0.2.0";
|
|
873
905
|
// 0.46.0: custody lifecycle on NomosBridge/NomosSession (onCustodyBreak/custodyStatus/salvage lanes).
|
|
874
906
|
const NOMOS_CLIENT_DEP = "^0.46.0";
|
|
875
907
|
|
|
@@ -955,7 +987,7 @@ function emitDartPackages(
|
|
|
955
987
|
dartOut: string,
|
|
956
988
|
domainHash: string,
|
|
957
989
|
packageInterface: Record<string, DomainInterface>,
|
|
958
|
-
packages: { readonly types: string; readonly client: string; readonly test?: string },
|
|
990
|
+
packages: { readonly types: string; readonly client: string; readonly test?: string; readonly compat?: string; readonly barrelExports?: "all" | "explicit" },
|
|
959
991
|
names: Readonly<Record<string, string>> | undefined,
|
|
960
992
|
compat?: NomosCompat,
|
|
961
993
|
): { written: string[]; cursed: string[] } {
|
|
@@ -964,8 +996,13 @@ function emitDartPackages(
|
|
|
964
996
|
fail(`dart.packages.${role}: '${String(name)}' is not a valid pub package name (lower_snake_case)`);
|
|
965
997
|
}
|
|
966
998
|
}
|
|
967
|
-
const roleNames = [
|
|
968
|
-
|
|
999
|
+
const roleNames = [
|
|
1000
|
+
packages.types,
|
|
1001
|
+
packages.client,
|
|
1002
|
+
...(packages.test !== undefined ? [packages.test] : []),
|
|
1003
|
+
...(packages.compat !== undefined ? [packages.compat] : []),
|
|
1004
|
+
];
|
|
1005
|
+
if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
|
|
969
1006
|
|
|
970
1007
|
const shared = collectDartSharedSymbols(modules);
|
|
971
1008
|
const written: string[] = [];
|
|
@@ -1075,16 +1112,40 @@ function emitDartPackages(
|
|
|
1075
1112
|
const aliasImportUris = [...typesSources.keys()]
|
|
1076
1113
|
.filter((f) => [...(declaredByFile.get(f) ?? [])].some((n) => aliasTargets.has(n)))
|
|
1077
1114
|
.map((f) => `${genRel}/${f}.dart`);
|
|
1115
|
+
// COMPAT ROUTING (architecture/dart_naming.md): when `packages.compat` is set, the `dart.names`
|
|
1116
|
+
// typedefs go into a SEPARATE migration-only package instead of the types barrel. The types barrel
|
|
1117
|
+
// then carries NO aliases (fully humane vs. compat separation); when compat is UNSET, aliases land
|
|
1118
|
+
// in the types barrel exactly as before.
|
|
1119
|
+
const compatPackage = packages.compat;
|
|
1120
|
+
const routeAliasesToCompat = compatPackage !== undefined && compatPackage !== "" && names !== undefined && Object.keys(names).length > 0;
|
|
1121
|
+
const barrelNames = routeAliasesToCompat ? undefined : names;
|
|
1122
|
+
const barrelAliasImportUris = routeAliasesToCompat ? [] : aliasImportUris;
|
|
1123
|
+
// BARREL PROMOTION POLICY (dart_naming.md): `'explicit'` on a MULTI-domain package stops the barrel
|
|
1124
|
+
// flat-exporting per-domain aggregate/read-model classes (consumers use `as`-prefixed per-domain
|
|
1125
|
+
// imports); the shared core (framework/contracts/activity) still flat-exports. A single-domain package
|
|
1126
|
+
// (no collision risk) is UNAFFECTED — byte-identical to `'all'`.
|
|
1127
|
+
const domainFileBases = new Set(modules.map((m) => m.name));
|
|
1128
|
+
const explicitBarrel = packages.barrelExports === "explicit" && modules.length > 1;
|
|
1129
|
+
const typesExportKeys = explicitBarrel
|
|
1130
|
+
? [...typesSources.keys()].filter((f) => !domainFileBases.has(f))
|
|
1131
|
+
: [...typesSources.keys()];
|
|
1132
|
+
const explicitBarrelNote = explicitBarrel
|
|
1133
|
+
? [
|
|
1134
|
+
`// barrelExports: 'explicit' — per-domain aggregate/read-model classes are NOT flat-exported here.`,
|
|
1135
|
+
`// Import a domain with a prefix instead, e.g. \`import '${genRel}/<domain>.dart' as <domain>;\`.`,
|
|
1136
|
+
]
|
|
1137
|
+
: [];
|
|
1078
1138
|
const typesBarrel = [
|
|
1079
1139
|
"// GENERATED by nomos-compile — the TYPES package's public entrypoint. DO NOT EDIT.",
|
|
1080
1140
|
"// Pure data: payload classes, read models, VOs/enums, typed read accessors over the",
|
|
1081
1141
|
"// injected NomosReads seams. Frontend-safe — no bridge, no sessions, no lifecycle.",
|
|
1082
1142
|
"library;",
|
|
1083
1143
|
"",
|
|
1084
|
-
...
|
|
1144
|
+
...explicitBarrelNote,
|
|
1145
|
+
...aliasImportLines(barrelNames, barrelAliasImportUris),
|
|
1085
1146
|
`export 'package:nomos_types/nomos_types.dart';`,
|
|
1086
|
-
...
|
|
1087
|
-
...dartAliasLines(
|
|
1147
|
+
...typesExportKeys.map((f) => `export '${genRel}/${f}.dart';`),
|
|
1148
|
+
...dartAliasLines(barrelNames, declared),
|
|
1088
1149
|
"",
|
|
1089
1150
|
];
|
|
1090
1151
|
const typesBarrelPath = path.join(pkgDir(packages.types), "lib", `${packages.types}.dart`);
|
|
@@ -1187,6 +1248,62 @@ function emitDartPackages(
|
|
|
1187
1248
|
);
|
|
1188
1249
|
}
|
|
1189
1250
|
|
|
1251
|
+
// ── the COMPAT package (optional, MIGRATION-ONLY) — pure `dart.names` typedefs over the types package ──
|
|
1252
|
+
// Routed here (instead of the types barrel) when `dart.packages.compat` is set: a standalone, clearly
|
|
1253
|
+
// labeled, deletable-when-done shim. The typedefs resolve because they import the types barrel (which
|
|
1254
|
+
// re-exports every generated symbol). Validated fail-closed by `dartAliasLines` against the types surface.
|
|
1255
|
+
if (routeAliasesToCompat) {
|
|
1256
|
+
const compatName = compatPackage!;
|
|
1257
|
+
if (!isPubPackageName(compatName)) {
|
|
1258
|
+
fail(`dart.packages.compat: '${compatName}' is not a valid pub package name (lower_snake_case)`);
|
|
1259
|
+
}
|
|
1260
|
+
const compatGen = freshGenDir(compatName);
|
|
1261
|
+
const typesImportForCompat = `package:${packages.types}/${packages.types}.dart`;
|
|
1262
|
+
const compatFile = [
|
|
1263
|
+
"// GENERATED by nomos-compile — MIGRATION-ONLY compat aliases. DO NOT EDIT.",
|
|
1264
|
+
`//`,
|
|
1265
|
+
`// MIGRATION-ONLY — delete this package (\`${compatName}\`) once callers have moved to the real`,
|
|
1266
|
+
`// names in \`${packages.types}\`. Every symbol below is a plain \`typedef\` over the real generated`,
|
|
1267
|
+
`// type; it exists solely to keep pre-rename imports compiling during a migration. It adds NO`,
|
|
1268
|
+
`// behaviour and SHOULD NOT accrete new entries — new code imports \`${packages.types}\` directly.`,
|
|
1269
|
+
"library;",
|
|
1270
|
+
"",
|
|
1271
|
+
`import '${typesImportForCompat}';`,
|
|
1272
|
+
...dartAliasLines(names, declared),
|
|
1273
|
+
"",
|
|
1274
|
+
];
|
|
1275
|
+
const compatSrcPath = path.join(compatGen, `${compatName}_aliases.dart`);
|
|
1276
|
+
writeFileSync(compatSrcPath, compatFile.join("\n"), "utf8");
|
|
1277
|
+
written.push(compatSrcPath);
|
|
1278
|
+
const compatBarrel = [
|
|
1279
|
+
"// GENERATED by nomos-compile — the COMPAT package's public entrypoint. DO NOT EDIT.",
|
|
1280
|
+
`// MIGRATION-ONLY — delete \`${compatName}\` once callers use the real names in \`${packages.types}\`.`,
|
|
1281
|
+
"library;",
|
|
1282
|
+
"",
|
|
1283
|
+
`export '${typesImportForCompat}';`,
|
|
1284
|
+
`export '${genRel}/${compatName}_aliases.dart';`,
|
|
1285
|
+
"",
|
|
1286
|
+
];
|
|
1287
|
+
const compatBarrelPath = path.join(pkgDir(compatName), "lib", `${compatName}.dart`);
|
|
1288
|
+
writeFileSync(compatBarrelPath, compatBarrel.join("\n"), "utf8");
|
|
1289
|
+
written.push(compatBarrelPath);
|
|
1290
|
+
scaffoldPubspec(
|
|
1291
|
+
pkgDir(compatName),
|
|
1292
|
+
[
|
|
1293
|
+
`name: ${compatName}`,
|
|
1294
|
+
`description: MIGRATION-ONLY Nomos name-compat shim (typedefs over ${packages.types}). Delete once migrated.`,
|
|
1295
|
+
`version: 0.1.0`,
|
|
1296
|
+
`publish_to: none # tenant-owned: remove to publish`,
|
|
1297
|
+
`environment:`,
|
|
1298
|
+
` sdk: ^3.5.0`,
|
|
1299
|
+
`dependencies:`,
|
|
1300
|
+
` ${packages.types}:`,
|
|
1301
|
+
` path: ../${packages.types} # co-emitted sibling; replace with a hosted constraint when publishing`,
|
|
1302
|
+
``,
|
|
1303
|
+
].join("\n"),
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1190
1307
|
return { written, cursed: cursedNameHints(declared, names) };
|
|
1191
1308
|
}
|
|
1192
1309
|
|
|
@@ -2345,7 +2462,7 @@ async function main(): Promise<void> {
|
|
|
2345
2462
|
typeof dartCfg.packages.client !== "string")))
|
|
2346
2463
|
) {
|
|
2347
2464
|
fail(
|
|
2348
|
-
`config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name", packages?: { types, client, test? }, names?: { Generated: "Alias" } }; got ${JSON.stringify(dartCfg)}`,
|
|
2465
|
+
`config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name", packages?: { types, client, test?, compat? }, names?: { Generated: "Alias" } }; got ${JSON.stringify(dartCfg)}`,
|
|
2349
2466
|
);
|
|
2350
2467
|
}
|
|
2351
2468
|
const dartPackages = typeof dartCfg === "object" ? dartCfg.packages : undefined;
|
package/src/directive.ts
CHANGED
|
@@ -310,6 +310,17 @@ export interface Directive<P = unknown> {
|
|
|
310
310
|
* to before this existed (the markers only emit the already-existing invariant era key, when used).
|
|
311
311
|
*/
|
|
312
312
|
readonly preconditions?: PreconditionDescriptor[];
|
|
313
|
+
/**
|
|
314
|
+
* THE DESIGNATED SURFACED RESOLVER (surfaced_merge_conflict.md — the DX layer). Set by
|
|
315
|
+
* `.resolves("field")`: this directive is THE designated resolver of its target aggregate's named
|
|
316
|
+
* `.merge(Surfaced(...))` field — the one the generated typed `resolve…` helper dispatches. EXACTLY ONE
|
|
317
|
+
* directive may resolve each Surfaced field, and every Surfaced field MUST have one (both enforced at
|
|
318
|
+
* `composeDomainModule`, fail-closed — see `surfaced_lint.ts`). NON-LAW, additive codegen metadata (the
|
|
319
|
+
* `description`/`grantsRelation` class): it changes NO manifest/hash — in the KERNEL sense any gated
|
|
320
|
+
* directive that set()s the field still resolves the conflict; this only designates which one the typed
|
|
321
|
+
* helper binds. Omit-when-absent (a non-resolving directive is byte-identical).
|
|
322
|
+
*/
|
|
323
|
+
readonly resolvesField?: string;
|
|
313
324
|
/**
|
|
314
325
|
* CALLER-SUPPLIED-ID CREATE-ONCE (`.creates(Agg)….withId(idFn)`). When set, the directive `creates` the
|
|
315
326
|
* aggregate at an APP-SUPPLIED stable id (`idFn(payload)`, like `.ensures`) rather than a minted id,
|
|
@@ -485,6 +496,17 @@ export interface RequirableDirective<P> extends Directive<P> {
|
|
|
485
496
|
* Accumulates + dedups; additive + non-destructive. A directive that never calls this is byte-identical.
|
|
486
497
|
*/
|
|
487
498
|
grants(relation: string): RequirableDirective<P>;
|
|
499
|
+
/**
|
|
500
|
+
* Designate this directive THE resolver of its target aggregate's Surfaced `field`
|
|
501
|
+
* (`directive("retitleDoc").mutates(Doc)….resolves("title")`). The generated typed resolution helper
|
|
502
|
+
* (TS + Dart) binds to it: `resolve…(conflict, chosenValue)` desugars to dispatching THIS directive.
|
|
503
|
+
* ESCAPE HATCH (kernel truth): any gated directive that set()s the field still resolves the conflict
|
|
504
|
+
* in the kernel sense — `.resolves()` only designates which one the typed helper dispatches. Enforced
|
|
505
|
+
* fail-closed at compile: the field must be a declared `.merge(Surfaced(...))` field of the target
|
|
506
|
+
* aggregate, EXACTLY ONE directive may resolve it, and every Surfaced field must have a resolver.
|
|
507
|
+
* NON-LAW + additive (the `.doc()` class) — a directive that never calls this is byte-identical.
|
|
508
|
+
*/
|
|
509
|
+
resolves(field: string): RequirableDirective<P>;
|
|
488
510
|
}
|
|
489
511
|
|
|
490
512
|
/** Stage 1: id + a referential marker (which fixes the target aggregate). */
|
|
@@ -871,6 +893,24 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
|
|
|
871
893
|
const mergedQueries = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
872
894
|
return makeRequirable({ ...base, birthsChild: true, declaredQueryReads: mergedQueries });
|
|
873
895
|
},
|
|
896
|
+
resolves(field: string): RequirableDirective<P> {
|
|
897
|
+
// THE DESIGNATED SURFACED RESOLVER: record which Surfaced field this directive resolves. Field
|
|
898
|
+
// existence / Surfaced-ness / exactly-one-per-field are judged at `composeDomainModule` (the
|
|
899
|
+
// aggregate handles are in hand there); here only the shape is checked. Non-law, additive.
|
|
900
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
901
|
+
throw new Error(
|
|
902
|
+
`directive('${base.id}').resolves(field) needs a non-empty field name — the Surfaced field ` +
|
|
903
|
+
`this directive is the designated resolver of (e.g. .resolves("title")).`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
if (base.resolvesField !== undefined && base.resolvesField !== field) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`directive('${base.id}').resolves("${field}"): already .resolves("${base.resolvesField}") — ` +
|
|
909
|
+
`a directive designates AT MOST ONE Surfaced field (write a second directive for the other).`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
return makeRequirable({ ...base, resolvesField: field });
|
|
913
|
+
},
|
|
874
914
|
grants(relation: string): RequirableDirective<P> {
|
|
875
915
|
// PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): record that this directive establishes
|
|
876
916
|
// `relation` (its plan folds a granting `writeTuple`). NO kernel/manifest effect — a pure static hint
|
package/src/drivers.ts
CHANGED
|
@@ -36,7 +36,8 @@ export type KernelDriverKind =
|
|
|
36
36
|
| "Conflict"
|
|
37
37
|
| "RemoveWins"
|
|
38
38
|
| "Counter"
|
|
39
|
-
| "LastPosition"
|
|
39
|
+
| "LastPosition"
|
|
40
|
+
| "Surfaced";
|
|
40
41
|
/**
|
|
41
42
|
* Drivers in the design palette the kernel does not yet merge. Currently EMPTY —
|
|
42
43
|
* every palette driver encodes. Kept as a (never-)union so a future palette
|
|
@@ -48,7 +49,7 @@ export type DriverKind = KernelDriverKind | PaletteOnlyKind;
|
|
|
48
49
|
|
|
49
50
|
export interface Driver {
|
|
50
51
|
readonly kind: DriverKind;
|
|
51
|
-
/** Only present for MapOf. */
|
|
52
|
+
/** Only present for MapOf / Surfaced. */
|
|
52
53
|
readonly inner?: Driver;
|
|
53
54
|
/** Only present for `LwwBy`: the named time-authority principal (a verified userId). */
|
|
54
55
|
readonly principal?: string;
|
|
@@ -95,6 +96,61 @@ export const LastPosition: Driver = { kind: "LastPosition" };
|
|
|
95
96
|
export const RemoveWins: Driver = { kind: "RemoveWins" };
|
|
96
97
|
export const Counter: Driver = { kind: "Counter" };
|
|
97
98
|
|
|
99
|
+
/** The Lww-class inner drivers `Surfaced` may wrap in v1 (wholesale-replace scalars —
|
|
100
|
+
* the pending set holds competing SCALAR values a human picks between). */
|
|
101
|
+
const SURFACED_INNER_KINDS: ReadonlySet<DriverKind> = new Set<DriverKind>([
|
|
102
|
+
"Lww",
|
|
103
|
+
"LwwCausal",
|
|
104
|
+
"LwwBy",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `merge(Surfaced(inner))` — the USER-RESOLVABLE third merge posture
|
|
109
|
+
* (architecture/surfaced_merge_conflict.md; kernel `merge_field` arm in
|
|
110
|
+
* `nomos2/kernel/src/lib.rs`). On a GENUINELY CONCURRENT write (the divergent
|
|
111
|
+
* 3-way merge lane — two session branches touching the field between syncs) the
|
|
112
|
+
* kernel neither silently folds to a winner NOR drops the field: it retains BOTH
|
|
113
|
+
* competing values as a bounded pending-observation set (cap 8, deterministically
|
|
114
|
+
* pruned) that a read surfaces as `{"__surfaced":{"pending":[{value, author,
|
|
115
|
+
* occurredAt}, ...]}}` — ALWAYS 2+ competing observations (a single-observation
|
|
116
|
+
* set read-collapses to the plain scalar) — so an app can show a "pick one" UI.
|
|
117
|
+
* Linear writes and
|
|
118
|
+
* fast-forwards behave EXACTLY as the inner driver (`Surfaced(LwwBy(p))` keeps
|
|
119
|
+
* `p`'s write filter); pending sets form ONLY under real concurrency.
|
|
120
|
+
*
|
|
121
|
+
* RESOLUTION IS AN ORDINARY NEXT WRITE — a `.mutates(...)` directive the domain
|
|
122
|
+
* author writes that `set()`s the field on top of the pending state. It
|
|
123
|
+
* fast-forwards the field to plain Resolved (byte-identical to any other field
|
|
124
|
+
* thereafter); no new write-authority primitive exists or is needed.
|
|
125
|
+
*
|
|
126
|
+
* CAVEATS: observations carry `{value, author, occurredAt}` where `author` (the
|
|
127
|
+
* writer principal) is nullable — an observation folded where no intent context
|
|
128
|
+
* exists carries no author. v1 wraps Lww-class SCALAR inners only: `Lww`,
|
|
129
|
+
* `LwwCausal`, `LwwBy(principal)` (a concurrent add to an `AddWins` set already
|
|
130
|
+
* commutes without conflict; surfacing a map/counter has no v1 meaning).
|
|
131
|
+
*/
|
|
132
|
+
export const Surfaced = (inner: Driver): Driver => {
|
|
133
|
+
if (inner === null || typeof inner !== "object" || typeof inner.kind !== "string") {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Surfaced(inner) needs an inner merge driver — e.g. Surfaced(Lww) or Surfaced(LwwBy("updatedAt")).`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!SURFACED_INNER_KINDS.has(inner.kind)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Surfaced(${inner.kind}) is not supported — Surfaced wraps an Lww-class SCALAR inner driver only ` +
|
|
141
|
+
`(Lww, LwwCausal, LwwBy) in v1. A pending set holds competing scalar values a human picks between; ` +
|
|
142
|
+
`'${inner.kind}' ${
|
|
143
|
+
inner.kind === "Surfaced"
|
|
144
|
+
? "is already Surfaced — nesting adds nothing"
|
|
145
|
+
: inner.kind === "AddWins" || inner.kind === "RemoveWins" || inner.kind === "Counter"
|
|
146
|
+
? "already merges concurrent writes without conflict by design"
|
|
147
|
+
: "is not a wholesale-replace scalar merge"
|
|
148
|
+
}.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return { kind: "Surfaced", inner };
|
|
152
|
+
};
|
|
153
|
+
|
|
98
154
|
// Drivers in the palette the kernel does NOT yet merge — currently NONE. A future
|
|
99
155
|
// palette driver added ahead of its `merge_field` arm goes here; `encodeDriver`
|
|
100
156
|
// throws for it rather than emit a wire driver the kernel can't merge.
|
|
@@ -127,6 +183,11 @@ export function encodeDriver(d: Driver): WireDriver {
|
|
|
127
183
|
case "MapOf":
|
|
128
184
|
if (!d.inner) throw new Error("MapOf driver is missing its inner driver");
|
|
129
185
|
return { MapOf: encodeDriver(d.inner) };
|
|
186
|
+
case "Surfaced":
|
|
187
|
+
// serde newtype variant `Driver::Surfaced(Box<Driver>)` → `{ "Surfaced": <inner> }`
|
|
188
|
+
// (externally tagged, mirroring MapOf exactly).
|
|
189
|
+
if (!d.inner) throw new Error("Surfaced driver is missing its inner driver");
|
|
190
|
+
return { Surfaced: encodeDriver(d.inner) };
|
|
130
191
|
default:
|
|
131
192
|
if (PALETTE_ONLY.has(d.kind)) {
|
|
132
193
|
throw new Error(
|