@githolon/dsl 0.84.0 → 0.85.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 CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.55.2
2
+
3
+ - Session-state DX (co2): explicit workspace lifecycle `state` on `listWorkspaces()` —
4
+ `connected | resident | parked` — so a listed-but-unopened workspace (e.g. a locally-born
5
+ child) is distinguishable at a glance, ending the "listed but `session(ws)` fails on first op"
6
+ footgun.
7
+ - `NomosRef.acquire(bridge)` — open a born/resident ref directly:
8
+ `await outcome.bornRefs.single.acquire(bridge)` (sugar over `acquireSession`, keyed by the
9
+ ref's workspace).
10
+ - `NomosSession.isConnected` + `assertConnected()` — the fail-at-bind guard: a clear, actionable
11
+ `StateError` instead of an opaque "session is not connected" on the first read/write.
12
+ - Patch bump — keeps the exact-minor `^0.55.0` pins valid, no generated-client re-pin needed.
13
+
1
14
  ## 0.55.1
2
15
 
3
16
  - Add `deriveWorkspaceName(namespace, key)` — re-derive the workspace name a keyed birth
@@ -689,6 +689,25 @@ class NomosOfferOutcome {
689
689
  .toList();
690
690
  }
691
691
 
692
+ /// OPEN A REF'S WORKSPACE DIRECTLY (co2 DX ask) — `await outcome.bornRefs.single.acquire(bridge)`. Sugar over
693
+ /// [NomosBridge.acquireSession] keyed by the ref's `workspace`: an already-open session returns as-is; a
694
+ /// device-resident/parked/just-born workspace opens LOCAL-ONLY in place (no cloud, no credentials); only a
695
+ /// genuinely-absent workspace needs [ifAbsentCredentials] (else a named [StateError]). Returns the live
696
+ /// [NomosSession] — so the failure surfaces HERE (at open), never later on the client's first query. Defined
697
+ /// in nomos_client (NOT on the pure `nomos_types` [NomosRef]) so the types package stays bridge-free.
698
+ extension NomosRefAcquire on NomosRef {
699
+ Future<NomosSession> acquire(
700
+ NomosBridge bridge, {
701
+ Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
702
+ }) {
703
+ if (workspace.isEmpty) {
704
+ throw ArgumentError.value(this, 'ref',
705
+ 'NomosRef.acquire: the ref carries no workspace — only a workspace-complete ref is openable');
706
+ }
707
+ return bridge.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials);
708
+ }
709
+ }
710
+
692
711
  /// What a GENUINE cloud open needs — returned by an [NomosBridge.acquireSession] `ifAbsentCredentials`
693
712
  /// callback, invoked ONLY when the workspace is nowhere on the local plane (never for a local-only or
694
713
  /// already-mounted workspace). Mirrors the cloud-shaped subset of [NomosBridge.connect]'s parameters.
@@ -1026,13 +1045,28 @@ class NomosBridge {
1026
1045
  List<String> get connectedSessions =>
1027
1046
  List.unmodifiable(_eventPlane._sessionNames);
1028
1047
 
1029
- /// ENUMERATE THE LOCAL PLANE — every workspace open on this device + its sync posture:
1030
- /// `[{ session, workspace, mounted, syncPosture: "local-only" | "cloud-bound" }]`. A local-only workspace
1031
- /// has no cloud counterpart (it was opened with no `cloud`); a cloud-bound one syncs per its own posture.
1032
- Future<List<Map<String, Object?>>> listWorkspaces() async =>
1033
- (((_asMap(await _call('listWorkspaces'))['workspaces']) as List?) ?? const [])
1034
- .map((e) => (e as Map).cast<String, Object?>())
1035
- .toList();
1048
+ /// ENUMERATE THE LOCAL PLANE — every workspace on this device + its lifecycle STATE and sync posture:
1049
+ /// `[{ session, workspace, state, mounted, parked, syncPosture }]`. The explicit **`state`** (co2 DX ask)
1050
+ /// is the field to branch on — it removes the "listed but `session(ws)` fails on first op" footgun:
1051
+ /// • `"connected"` — a live open session; `bridge.session(ws)` works, reads/writes flow NOW;
1052
+ /// • `"resident"` — custody is on the device but no session is open yet (e.g. a locally-born child) —
1053
+ /// call `bridge.acquireSession(ws)` / `ref.acquire(bridge)` before using it;
1054
+ /// • `"parked"` — custody resident but the holon is LRU-disposed; it re-mounts on demand (acquire).
1055
+ /// `syncPosture` is `"local-only"` (no cloud counterpart) or `"cloud-bound"`.
1056
+ Future<List<Map<String, Object?>>> listWorkspaces() async {
1057
+ final connected = _eventPlane._sessionNames.toSet();
1058
+ return (((_asMap(await _call('listWorkspaces'))['workspaces']) as List?) ?? const [])
1059
+ .map((e) => (e as Map).cast<String, Object?>())
1060
+ .map((w) {
1061
+ final ws = w['workspace']?.toString() ?? '';
1062
+ // `connected` is the app's own connect() truth; `parked`/resident come from the runner realm.
1063
+ w['state'] = (w['parked'] == true)
1064
+ ? 'parked'
1065
+ : (connected.contains(ws) ? 'connected' : 'resident');
1066
+ return w;
1067
+ })
1068
+ .toList();
1069
+ }
1036
1070
 
1037
1071
  /// Open a workspace on this plane: boot its LOCAL githolon under the installed law (from [restoreFrom]
1038
1072
  /// custody, or a cloud pull when [cloud] is given). This is the sugar the single-workspace path uses; a
@@ -2362,6 +2396,26 @@ class NomosSession extends NomosBridge {
2362
2396
  @override
2363
2397
  String? get _sessionName => workspace;
2364
2398
 
2399
+ /// Whether this workspace has a LIVE open session on the plane (the app `connect`/`acquireSession`d it). A
2400
+ /// lazily-created `bridge.session(ws)` handle is valid to HOLD before connect, but an op on it fails until
2401
+ /// the workspace is open. Check this — or prefer `bridge.acquireSession(ws)` / `ref.acquire(bridge)`, which
2402
+ /// open-then-return — so a not-connected mistake is caught at BIND, not on the client's first query.
2403
+ bool get isConnected => parent.connectedSessions.contains(workspace);
2404
+
2405
+ /// Fail-at-bind guard (co2 DX ask): throw a CLEAR, actionable [StateError] if this workspace is not open
2406
+ /// yet, so a generated client can call it in its constructor and surface the mistake immediately instead of
2407
+ /// an opaque "session is not connected" on the first read/write.
2408
+ void assertConnected() {
2409
+ if (!isConnected) {
2410
+ throw StateError(
2411
+ 'NomosSession("$workspace") is not connected — the workspace is not open on this device\'s plane. '
2412
+ 'Open it first: `await bridge.acquireSession("$workspace")` (or `await ref.acquire(bridge)` for a '
2413
+ 'born child), then bind the client to the returned session. A held-but-unopened session handle is '
2414
+ 'valid to construct but cannot serve ops until connected.',
2415
+ );
2416
+ }
2417
+ }
2418
+
2365
2419
  /// Sibling workspaces resolve on the shared plane — delegate to the parent's registry (never nest a
2366
2420
  /// session inside a session).
2367
2421
  @override
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.55.1
14
+ version: 0.55.2
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -41,14 +41,21 @@ class FakeRunner implements BridgeTransport {
41
41
  result = {'workspace': args['workspace']};
42
42
  break;
43
43
  case 'listWorkspaces':
44
+ // Realm truth: a CONNECTED workspace is mounted (holon live); a resident-but-unconnected one is
45
+ // parked custody. Reflect connects so the derived lifecycle `state` is observable in both postures.
46
+ final connected = sent
47
+ .where((m) => m['op'] == 'connect')
48
+ .map((m) => ((m['args'] as Map)['workspace']).toString())
49
+ .toSet();
50
+ final all = {...resident, ...connected};
44
51
  result = {
45
52
  'workspaces': [
46
- for (final ws in resident)
53
+ for (final ws in all)
47
54
  {
48
55
  'session': ws,
49
56
  'workspace': ws,
50
- 'mounted': false,
51
- 'parked': true,
57
+ 'mounted': connected.contains(ws),
58
+ 'parked': !connected.contains(ws) && resident.contains(ws),
52
59
  'syncPosture': 'local-only'
53
60
  }
54
61
  ]
@@ -149,4 +156,64 @@ void main() {
149
156
  expect(identical(viaSession, bridge.session('estate')), isTrue);
150
157
  await bridge.dispose();
151
158
  });
159
+
160
+ // ── SESSION-STATE DX cluster (co2): explicit lifecycle state + fail-at-bind + openable refs ──
161
+
162
+ test('listWorkspaces() reports explicit state: resident/parked before open, connected after', () async {
163
+ final runner = FakeRunner(resident: ['estate']); // realm returns mounted:false, parked:true
164
+ final bridge = NomosBridge(runner);
165
+ await bridge.ready;
166
+
167
+ // Before any connect: the plane-resident (parked) workspace surfaces with an explicit non-connected state.
168
+ var list = await bridge.listWorkspaces();
169
+ final before = list.firstWhere((w) => w['workspace'] == 'estate');
170
+ expect(before['state'], 'parked', reason: 'parked custody is not connected — the app must acquire it first');
171
+
172
+ // After connecting it, the SAME workspace reports connected.
173
+ await bridge.connect(workspace: 'estate', session: 'estate');
174
+ list = await bridge.listWorkspaces();
175
+ final after = list.firstWhere((w) => w['workspace'] == 'estate');
176
+ expect(after['state'], 'connected');
177
+ await bridge.dispose();
178
+ });
179
+
180
+ test('NomosSession.isConnected + assertConnected: fail at BIND, not on first query', () async {
181
+ final runner = FakeRunner();
182
+ final bridge = NomosBridge(runner);
183
+ await bridge.ready;
184
+
185
+ // A held-but-unopened handle is valid to construct but not connected — assertConnected throws NOW.
186
+ final lazy = bridge.session('estate');
187
+ expect(lazy.isConnected, isFalse);
188
+ expect(lazy.assertConnected, throwsStateError);
189
+
190
+ await bridge.connect(workspace: 'estate', session: 'estate');
191
+ final live = bridge.session('estate');
192
+ expect(live.isConnected, isTrue);
193
+ expect(live.assertConnected, returnsNormally);
194
+ await bridge.dispose();
195
+ });
196
+
197
+ test('NomosRef.acquire(bridge) opens a born/resident ref directly → a live session', () async {
198
+ final runner = FakeRunner(resident: ['estate-42']);
199
+ final bridge = NomosBridge(runner);
200
+ await bridge.ready;
201
+
202
+ // The shape a `.births()` outcome yields: outcome.bornRefs.single is a NomosRef on the born workspace.
203
+ final ref = NomosRef(workspace: 'estate-42', type: 'nomos.Workspace', id: 'estate-42');
204
+ final session = await ref.acquire(bridge); // no credentials — resident opens local-only
205
+ expect(session.workspace, 'estate-42');
206
+ expect(session.isConnected, isTrue);
207
+ expect(identical(session, bridge.session('estate-42')), isTrue);
208
+ await bridge.dispose();
209
+ });
210
+
211
+ test('NomosRef.acquire refuses a workspace-less ref (a bad locator fails HERE, not later)', () async {
212
+ final runner = FakeRunner();
213
+ final bridge = NomosBridge(runner);
214
+ await bridge.ready;
215
+ final bad = NomosRef(workspace: '', type: 'nomos.Workspace', id: 'x');
216
+ expect(() => bad.acquire(bridge), throwsArgumentError);
217
+ await bridge.dispose();
218
+ });
152
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -837,6 +837,21 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
837
837
  ` return ${className}(session, actor: actor${encPresent ? ", keyStore: keyStore" : ""}).._preflighted = true;`,
838
838
  ` }`,
839
839
  ``,
840
+ ` /// OPEN-THEN-BIND (session_bound_clients.md ask 3) — the ONE-CALL path for a born/resident workspace:`,
841
+ ` /// \`await ${className}.open(bridge, ref: outcome.bornRefs.single)\` (or \`workspace: 'estate-42'\`).`,
842
+ ` /// It ACQUIRES the workspace's session on [bridge] first (an already-open one is reused; a device-`,
843
+ ` /// resident/parked/just-born one opens local-only in place) and THEN binds — so a not-open workspace`,
844
+ ` /// fails HERE, at open, never later on the client's first query. For a genuinely-absent cloud workspace,`,
845
+ ` /// \`acquireSession(ws, ifAbsentCredentials: ...)\` it yourself then [bind].`,
846
+ ` static Future<${className}> open(NomosBridge bridge, {NomosRef? ref, String? workspace, String? actor${encPresent ? ", NomosKeyStore? keyStore" : ""}}) async {`,
847
+ ` final ws = ref?.workspace ?? workspace;`,
848
+ ` if (ws == null || ws.isEmpty) {`,
849
+ ` throw ArgumentError('${className}.open: pass a workspace: or ref: — the workspace to open and bind');`,
850
+ ` }`,
851
+ ` final session = await bridge.acquireSession(ws);`,
852
+ ` return bind(session, actor: actor${encPresent ? ", keyStore: keyStore" : ""});`,
853
+ ` }`,
854
+ ``,
840
855
  ` /// The session workspace this client is bound to (from the [NomosSession] handle, when constructed with`,
841
856
  ` /// one) — the \`workspace\` of every read row's canonical [NomosRef]. '' on a bare (non-session) bridge.`,
842
857
  ` String get _ws {`,
@@ -47,7 +47,7 @@
47
47
  */
48
48
  import { execFileSync } from "node:child_process";
49
49
  import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
50
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
50
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
51
51
  import { createRequire } from "node:module";
52
52
  import path from "node:path";
53
53
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -684,6 +684,60 @@ function removeLegacyFlatDartLayout(dartOut: string, moduleNames: readonly strin
684
684
  * analyzer-clean plug-and-play entrypoint (zero `show`/`hide`; framework-shared
685
685
  * symbols dedup into `nomos_framework.dart`, so no export is ambiguous).
686
686
  */
687
+ /**
688
+ * COMPILE ATOMICITY (co2 dev report) — a failed compile MUST NOT delete the tenant's already-good generated
689
+ * packages. Both Dart emit paths used to `rmSync(genDir)` up-front and then write; a mid-emit throw (a codegen
690
+ * error, a flow-gate refusal surfaced during emit) left the dir WIPED or partial, so the dev could no longer
691
+ * `dart analyze` / open the app. Instead, each generated dir is renamed ASIDE to `<dir>.nomos-bak`, a fresh
692
+ * empty dir is created, and the emit writes into it. On success the whole emit `commit()`s (drops the backups);
693
+ * on ANY throw it `rollback()`s (removes the partial dir, restores the backup). The transactions are collected
694
+ * module-side and committed/rolled-back once around the whole Dart emit (the CLI is single-threaded per run).
695
+ */
696
+ const _genDirTx: { commit(): void; rollback(): void }[] = [];
697
+ let _genDirTxHandlerInstalled = false;
698
+ /** Install the ONE finalizer. A compile refusal is `fail()` → `process.exit(1)`, which BYPASSES try/catch —
699
+ * so the only reliable place to finalize the transactional gen dirs is the process `exit` event (a clean
700
+ * exit commits, any non-zero exit rolls back). Sync only (rmSync/renameSync are sync — required in `exit`). */
701
+ function installGenDirTxHandler(): void {
702
+ if (_genDirTxHandlerInstalled) return;
703
+ _genDirTxHandlerInstalled = true;
704
+ process.on("exit", (code) => {
705
+ for (const t of _genDirTx) {
706
+ try {
707
+ if (code === 0) t.commit();
708
+ else t.rollback();
709
+ } catch {
710
+ /* best-effort finalize — never mask the real exit */
711
+ }
712
+ }
713
+ });
714
+ }
715
+ /** Begin a transactional regeneration of `genDir` (see {@link _genDirTx}): back the prior dir aside + create a
716
+ * fresh one, and register idempotent commit/rollback finalized at process exit. Replaces the old destructive
717
+ * `rmSync(genDir); mkdirSync(genDir)` so a mid-emit `fail()`/throw restores the prior good package. */
718
+ function beginAtomicGenDir(genDir: string): void {
719
+ installGenDirTxHandler();
720
+ const bak = `${genDir}.nomos-bak`;
721
+ rmSync(bak, { recursive: true, force: true }); // clear any stale backup from a previously-killed run
722
+ const hadPrior = existsSync(genDir);
723
+ if (hadPrior) renameSync(genDir, bak);
724
+ mkdirSync(genDir, { recursive: true });
725
+ let done = false;
726
+ _genDirTx.push({
727
+ commit() {
728
+ if (done) return;
729
+ done = true;
730
+ rmSync(bak, { recursive: true, force: true });
731
+ },
732
+ rollback() {
733
+ if (done) return;
734
+ done = true;
735
+ rmSync(genDir, { recursive: true, force: true });
736
+ if (hadPrior) renameSync(bak, genDir);
737
+ },
738
+ });
739
+ }
740
+
687
741
  function emitDart(
688
742
  modules: readonly DomainModule[],
689
743
  dartOut: string,
@@ -698,8 +752,8 @@ function emitDart(
698
752
  const sourceByFile = new Map<string, string>(); // fileBase → src, for precise alias-import selection
699
753
  const genDir = path.join(dartOut, DART_GENERATED_SUBDIR);
700
754
  removeLegacyFlatDartLayout(dartOut, modules.map((m) => m.name));
701
- rmSync(genDir, { recursive: true, force: true });
702
- mkdirSync(genDir, { recursive: true });
755
+ // Transactional: back the prior generated dir aside instead of destroying it (compile atomicity).
756
+ beginAtomicGenDir(genDir);
703
757
 
704
758
  // 1. optional vendored framework support (barrel + src/), copied byte-for-byte except
705
759
  // the barrel's schema_validation export (see DART_SUPPORT_SRC).
@@ -1073,8 +1127,8 @@ function emitDartPackages(
1073
1127
  const pkgDir = (name: string): string => path.join(dartOut, name);
1074
1128
  const freshGenDir = (name: string): string => {
1075
1129
  const g = path.join(pkgDir(name), "lib", DART_GENERATED_SUBDIR);
1076
- rmSync(g, { recursive: true, force: true });
1077
- mkdirSync(g, { recursive: true });
1130
+ // Transactional: back the prior dir aside instead of destroying it (compile atomicity).
1131
+ beginAtomicGenDir(g);
1078
1132
  return g;
1079
1133
  };
1080
1134
 
@@ -2604,6 +2658,8 @@ async function main(): Promise<void> {
2604
2658
  cfgDir,
2605
2659
  typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
2606
2660
  );
2661
+ // COMPILE ATOMICITY (co2): the Dart emit is transactional (beginAtomicGenDir) — a mid-emit fail()/throw
2662
+ // restores the prior good packages instead of leaving them wiped/partial. Finalized at process exit.
2607
2663
  if (dartPackages !== undefined) {
2608
2664
  const r = emitDartPackages(domainModules, dartOut, domainHash, packageInterface, dartPackages, dartNames, nomosCompat);
2609
2665
  dartFiles = r.written;