@klum-db/lobby 0.4.0-pre.0 → 0.4.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,6 +8,399 @@ var __export = (target, all) => {
8
8
  __defProp(target, name, { get: all[name], enumerable: true });
9
9
  };
10
10
 
11
+ // src/portal/share-link.ts
12
+ var share_link_exports = {};
13
+ __export(share_link_exports, {
14
+ ShareLinkResolutionError: () => ShareLinkResolutionError,
15
+ buildShareLinkForClient: () => buildShareLinkForClient,
16
+ resolveShareLinkAgainstRegistry: () => resolveShareLinkAgainstRegistry
17
+ });
18
+ import {
19
+ parseShareLink,
20
+ buildShareLink as buildShareLinkFromParts
21
+ } from "@noy-db/hub/share-link";
22
+ async function registryRows(registry) {
23
+ await registry.list();
24
+ return registry.query().toArray();
25
+ }
26
+ async function resolveShareLinkAgainstRegistry(link, registry) {
27
+ const parsed = typeof link === "string" || link instanceof URL ? parseShareLink(link) : link;
28
+ const rows = await registryRows(registry);
29
+ const row = rows.find((r) => r.handle === parsed.vaultHandle);
30
+ if (!row) {
31
+ throw new ShareLinkResolutionError(
32
+ "UNKNOWN_VAULT_HANDLE",
33
+ `share link addresses vault handle "${parsed.vaultHandle}", which is not registered in this fleet.`
34
+ );
35
+ }
36
+ return {
37
+ link: parsed,
38
+ row,
39
+ consoleRoute: {
40
+ vaultId: row.vaultId,
41
+ collection: parsed.collection,
42
+ recordId: parsed.recordId,
43
+ ...parsed.period !== void 0 ? { period: parsed.period } : {},
44
+ ...parsed.version !== void 0 ? { version: parsed.version } : {}
45
+ }
46
+ };
47
+ }
48
+ async function buildShareLinkForClient(opts, registry) {
49
+ const rows = await registryRows(registry);
50
+ const row = rows.find((r) => r.partitionKey === opts.client && r.group === opts.group);
51
+ if (!row) {
52
+ throw new ShareLinkResolutionError(
53
+ "UNKNOWN_CLIENT",
54
+ `client "${opts.client}" is not registered in group "${opts.group}".`
55
+ );
56
+ }
57
+ if (row.handle === void 0) {
58
+ throw new ShareLinkResolutionError(
59
+ "NO_PORTAL_HANDLE",
60
+ `client "${opts.client}" has no portal vault handle \u2014 provision the portal before minting share links.`
61
+ );
62
+ }
63
+ return buildShareLinkFromParts(
64
+ {
65
+ vaultHandle: row.handle,
66
+ collection: opts.collection,
67
+ recordId: opts.record,
68
+ ...opts.period !== void 0 ? { period: opts.period } : {},
69
+ ...opts.version !== void 0 ? { version: opts.version } : {},
70
+ ...opts.grantToken !== void 0 ? { grantToken: opts.grantToken } : {}
71
+ },
72
+ opts.base
73
+ );
74
+ }
75
+ var ShareLinkResolutionError;
76
+ var init_share_link = __esm({
77
+ "src/portal/share-link.ts"() {
78
+ "use strict";
79
+ ShareLinkResolutionError = class extends Error {
80
+ constructor(code, message) {
81
+ super(message);
82
+ this.code = code;
83
+ }
84
+ code;
85
+ name = "ShareLinkResolutionError";
86
+ };
87
+ }
88
+ });
89
+
90
+ // src/federation/insight-auto-push.ts
91
+ var InsightAutoPush;
92
+ var init_insight_auto_push = __esm({
93
+ "src/federation/insight-auto-push.ts"() {
94
+ "use strict";
95
+ InsightAutoPush = class {
96
+ constructor(recompute, isSource, onError = (err, pk) => console.warn(`[klum-db] insight auto-push failed for shard "${pk}":`, err), debounceMs) {
97
+ this.recompute = recompute;
98
+ this.isSource = isSource;
99
+ this.onError = onError;
100
+ this.debounceMs = debounceMs;
101
+ }
102
+ recompute;
103
+ isSource;
104
+ onError;
105
+ debounceMs;
106
+ dirty = /* @__PURE__ */ new Set();
107
+ pending = null;
108
+ timer = null;
109
+ resolvePending = null;
110
+ flushing = false;
111
+ /** Called from a shard's onAfterWrite hook. Marks the shard dirty + schedules a flush. */
112
+ noteWrite(partitionKey, collection) {
113
+ if (!this.isSource(collection)) return;
114
+ this.dirty.add(partitionKey);
115
+ if (this.debounceMs !== void 0) this.scheduleDebounced();
116
+ else this.schedule();
117
+ }
118
+ /** Resolve once no flush is pending (drains rescheduled flushes too). */
119
+ async whenSettled() {
120
+ while (this.pending) await this.pending;
121
+ }
122
+ // ── microtask path (unchanged default) ──
123
+ schedule() {
124
+ if (this.pending) return;
125
+ this.pending = this.runFlush().finally(() => {
126
+ this.pending = null;
127
+ if (this.dirty.size > 0) this.schedule();
128
+ });
129
+ }
130
+ // ── debounce path (#13) ──
131
+ scheduleDebounced() {
132
+ if (!this.pending) this.pending = new Promise((r) => {
133
+ this.resolvePending = r;
134
+ });
135
+ if (this.timer) clearTimeout(this.timer);
136
+ this.timer = setTimeout(() => {
137
+ this.timer = null;
138
+ if (this.flushing) return;
139
+ this.flushing = true;
140
+ void this.runFlush().finally(() => {
141
+ this.flushing = false;
142
+ if (this.dirty.size > 0) {
143
+ this.scheduleDebounced();
144
+ } else {
145
+ const resolve = this.resolvePending;
146
+ this.resolvePending = null;
147
+ this.pending = null;
148
+ resolve?.();
149
+ }
150
+ });
151
+ }, this.debounceMs);
152
+ }
153
+ async runFlush() {
154
+ await Promise.resolve();
155
+ const pks = [...this.dirty];
156
+ this.dirty.clear();
157
+ for (const pk of pks) {
158
+ try {
159
+ await this.recompute(pk);
160
+ } catch (err) {
161
+ this.onError(err, pk);
162
+ }
163
+ }
164
+ }
165
+ };
166
+ }
167
+ });
168
+
169
+ // src/federation/read-model.ts
170
+ var read_model_exports = {};
171
+ __export(read_model_exports, {
172
+ PostureViolationError: () => PostureViolationError,
173
+ ReadModel: () => ReadModel,
174
+ deriveShardSummary: () => deriveShardSummary,
175
+ openReadModel: () => openReadModel
176
+ });
177
+ import { ValidationError } from "@noy-db/hub/cargo";
178
+ function deriveShardSummary(spec, records, row) {
179
+ const ctx = {
180
+ vaultId: row.vaultId,
181
+ partitionKey: row.partitionKey,
182
+ schemaVersion: row.schemaVersion
183
+ };
184
+ return spec.derive(records, ctx);
185
+ }
186
+ function checkPosture(model, row, shard) {
187
+ const declared = new Set(model.posture.surface);
188
+ const undeclared = Object.keys(row).filter((k) => !declared.has(k) && !PROVENANCE_FIELDS.has(k));
189
+ if (undeclared.length > 0) throw new PostureViolationError(model.name, undeclared, shard);
190
+ }
191
+ async function openReadModel(group, opts) {
192
+ const target = opts.vault;
193
+ if (target === group.name || target.startsWith(`${group.name}--`)) {
194
+ throw new ValidationError(
195
+ `openReadModel: vault "${target}" is the "${group.name}" group itself or one of its shards \u2014 the read-model must live outside the group it composes.`
196
+ );
197
+ }
198
+ if (opts.models.length === 0) {
199
+ throw new ValidationError("openReadModel: at least one model is required.");
200
+ }
201
+ const seen = /* @__PURE__ */ new Set();
202
+ for (const m of opts.models) {
203
+ if (seen.has(m.name)) throw new ValidationError(`openReadModel: duplicate model name "${m.name}".`);
204
+ seen.add(m.name);
205
+ if (m.posture.surface.length === 0) {
206
+ throw new ValidationError(`openReadModel: model "${m.name}" declares an empty posture.surface.`);
207
+ }
208
+ }
209
+ const vault = await group.db.openVault(target);
210
+ const rm = new ReadModel(group, target, vault, opts.models, opts.shards);
211
+ const autoPush = opts.freshness?.autoPush;
212
+ if (autoPush) rm._armAutoPush(autoPush === true ? {} : autoPush);
213
+ return rm;
214
+ }
215
+ var PostureViolationError, PROVENANCE_FIELDS, ReadModel;
216
+ var init_read_model = __esm({
217
+ "src/federation/read-model.ts"() {
218
+ "use strict";
219
+ init_insight_auto_push();
220
+ PostureViolationError = class extends Error {
221
+ constructor(model, fields, shard) {
222
+ super(
223
+ `read-model "${model}": derive emitted undeclared field(s) [${fields.join(", ")}] for shard "${shard}" \u2014 every emitted field must be listed in posture.surface (fail closed; nothing was written).`
224
+ );
225
+ this.model = model;
226
+ this.fields = fields;
227
+ this.shard = shard;
228
+ }
229
+ model;
230
+ fields;
231
+ shard;
232
+ name = "PostureViolationError";
233
+ };
234
+ PROVENANCE_FIELDS = /* @__PURE__ */ new Set(["_shard", "_sourceId", "_sourceVersion"]);
235
+ ReadModel = class {
236
+ /** @internal */
237
+ constructor(group, vaultName, vault, models, shards) {
238
+ this.group = group;
239
+ this.vaultName = vaultName;
240
+ this.vault = vault;
241
+ this.models = models;
242
+ this.shards = shards;
243
+ }
244
+ group;
245
+ vaultName;
246
+ vault;
247
+ models;
248
+ shards;
249
+ /** @internal — auto-push controller; armed by {@link _armAutoPush}. */
250
+ autoPush;
251
+ /**
252
+ * @internal — wire the #13 controller (spec § 3). Trigger is the
253
+ * Noydb-level change stream filtered to this group's shard vaults
254
+ * (`<group>--<pk>`); the read-model vault can never match that prefix
255
+ * (guarded at open), so its own writes can't loop back. Recompute is
256
+ * a per-shard `refresh({ only })` — rollup re-reduces and mirror
257
+ * reconciles just that shard; other shards' rows are untouched
258
+ * (registered shards are preserved by the reconcile asymmetry).
259
+ */
260
+ _armAutoPush(cfg) {
261
+ const controller = new InsightAutoPush(
262
+ async (partitionKey) => {
263
+ await this.refresh({
264
+ only: [partitionKey],
265
+ ...cfg.minVersion !== void 0 ? { minVersion: cfg.minVersion } : {}
266
+ });
267
+ },
268
+ (collection) => this.models.some((m) => m.source === collection),
269
+ void 0,
270
+ cfg.debounceMs
271
+ );
272
+ this.autoPush = controller;
273
+ const prefix = `${this.group.name}--`;
274
+ this.group.db.on("change", (e) => {
275
+ if (!e.vault.startsWith(prefix)) return;
276
+ controller.noteWrite(e.vault.slice(prefix.length), e.collection);
277
+ });
278
+ }
279
+ /**
280
+ * Await any pending auto-push flush. Resolves immediately when
281
+ * `freshness.autoPush` is off or nothing is pending.
282
+ */
283
+ async whenSettled() {
284
+ if (this.autoPush) await this.autoPush.whenSettled();
285
+ }
286
+ /** An output collection of the read-model vault (keyed by model `name`). */
287
+ collection(model) {
288
+ return this.vault.collection(model);
289
+ }
290
+ /**
291
+ * Explicit refresh: for each model, read every eligible shard's
292
+ * `source`, posture-check, and write — rollup: one summary row per
293
+ * shard (id = partitionKey); mirror: one row per source record
294
+ * (id = `${shard}:${idOf(row)}`). Every row is stamped with `_shard`
295
+ * + `_sourceVersion` (mirror rows additionally `_sourceId`).
296
+ *
297
+ * Reconciliation (spec § 4): rows whose source record is gone, and
298
+ * all rows of a shard that LEFT the group, are deleted (`retracted`).
299
+ * Rows of a merely-unreachable shard are PRESERVED — offline is not
300
+ * departure; those shards land in `skippedVaults` instead.
301
+ */
302
+ async refresh(options = {}) {
303
+ const resolved = await this.group.resolveEligible({
304
+ ...options.minVersion !== void 0 ? { minVersion: options.minVersion } : {},
305
+ ...options.only !== void 0 ? { only: options.only } : {},
306
+ ...options.failFast !== void 0 ? { failFast: options.failFast } : {}
307
+ });
308
+ const eligible = this.shards ? resolved.eligible.filter((r) => this.shards(r)) : resolved.eligible;
309
+ const skipped = resolved.skipped;
310
+ let written = 0;
311
+ let retracted = 0;
312
+ for (const model of this.models) {
313
+ const results = await this.group.db.queryAcross(
314
+ eligible.map((r) => r.vaultId),
315
+ async (vault) => {
316
+ this.group.template.configure(vault);
317
+ const coll = vault.collection(model.source);
318
+ const records = await coll.list();
319
+ if (model.kind === "rollup") return records.map((record) => ({ record, id: "", version: 0 }));
320
+ return Promise.all(records.map(async (record) => {
321
+ const id = model.idOf(record);
322
+ const meta = await coll.getMetadata(id);
323
+ return { record, id, version: meta?.version ?? 0 };
324
+ }));
325
+ },
326
+ { create: false, ...options.concurrency !== void 0 ? { concurrency: options.concurrency } : {} }
327
+ );
328
+ const out = this.vault.collection(model.name);
329
+ const expected = /* @__PURE__ */ new Set();
330
+ const healthyShards = /* @__PURE__ */ new Set();
331
+ for (let i = 0; i < eligible.length; i++) {
332
+ const row = eligible[i];
333
+ const res = results[i];
334
+ if (!res || res.result === void 0) {
335
+ if (options.failFast && res?.error) throw res.error;
336
+ if (!skipped.some((s) => s.vaultId === row.vaultId)) {
337
+ skipped.push({ vaultId: row.vaultId, reason: "error", ...res?.error ? { error: res.error } : {} });
338
+ }
339
+ continue;
340
+ }
341
+ healthyShards.add(row.partitionKey);
342
+ if (model.kind === "rollup") {
343
+ const summary = deriveShardSummary(model, res.result.map((e) => e.record), row);
344
+ checkPosture(model, summary, row.partitionKey);
345
+ await out.put(row.partitionKey, {
346
+ ...summary,
347
+ _shard: row.partitionKey,
348
+ _sourceVersion: row.schemaVersion
349
+ });
350
+ expected.add(row.partitionKey);
351
+ written++;
352
+ } else {
353
+ for (const entry of res.result) {
354
+ checkPosture(model, entry.record, row.partitionKey);
355
+ const id = `${row.partitionKey}:${entry.id}`;
356
+ await out.put(id, {
357
+ ...entry.record,
358
+ _shard: row.partitionKey,
359
+ _sourceId: entry.id,
360
+ _sourceVersion: entry.version
361
+ });
362
+ expected.add(id);
363
+ written++;
364
+ }
365
+ }
366
+ }
367
+ retracted += await this.reconcile(out, model, expected, healthyShards);
368
+ }
369
+ return { written, retracted, skippedVaults: skipped };
370
+ }
371
+ /**
372
+ * Delete stale rows: id not in `expected` AND the row's shard is
373
+ * healthy this refresh (source record deleted), no longer in the
374
+ * group registry (shard departed), or outside the audience predicate
375
+ * (scope narrowed — out-of-scope data never lingers, spec § 5).
376
+ * Unreachable-but-registered in-scope shards are preserved.
377
+ */
378
+ async reconcile(out, model, expected, healthyShards) {
379
+ await this.group.registry.list();
380
+ const registryRows2 = new Map(
381
+ this.group.registry.query().toArray().filter((r) => r.group === this.group.name).map((r) => [r.partitionKey, r])
382
+ );
383
+ await out.list();
384
+ const rows = out.query().toArray();
385
+ let retracted = 0;
386
+ for (const row of rows) {
387
+ const shard = row["_shard"];
388
+ if (shard === void 0) continue;
389
+ const id = model.kind === "rollup" ? shard : `${shard}:${String(row["_sourceId"])}`;
390
+ if (expected.has(id)) continue;
391
+ const registryRow = registryRows2.get(shard);
392
+ const outOfScope = registryRow !== void 0 && this.shards !== void 0 && !this.shards(registryRow);
393
+ if (healthyShards.has(shard) || registryRow === void 0 || outOfScope) {
394
+ await out.delete(id);
395
+ retracted++;
396
+ }
397
+ }
398
+ return retracted;
399
+ }
400
+ };
401
+ }
402
+ });
403
+
11
404
  // src/bundle/uint32.ts
12
405
  function readUint32BE(bytes, offset) {
13
406
  return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
@@ -205,7 +598,7 @@ import {
205
598
  walkClosure,
206
599
  extractPartition,
207
600
  describeExtraction
208
- } from "@noy-db/hub/bundle";
601
+ } from "@noy-db/hub/cargo";
209
602
  import { readPodHeader as readPodHeader2 } from "@noy-db/hub/pod";
210
603
  import { sha256Hex as sha256Hex2, generateULID as generateULID2 } from "@noy-db/hub/cargo";
211
604
  function asIdArray(v) {
@@ -433,7 +826,7 @@ var init_field_authority = __esm({
433
826
 
434
827
  // src/interchange/merge-compartment.ts
435
828
  import { diffVault } from "@noy-db/hub/cargo";
436
- import { decryptExtractedPartition } from "@noy-db/hub/bundle";
829
+ import { decryptExtractedPartition } from "@noy-db/hub/cargo";
437
830
  function strategyFor(opts, collection) {
438
831
  if (typeof opts === "string") return opts;
439
832
  return opts[collection] ?? opts.default ?? "manual-queue";
@@ -728,7 +1121,7 @@ __export(surface_exports, {
728
1121
  proposeSurface: () => proposeSurface
729
1122
  });
730
1123
  import { generateULID as generateULID3 } from "@noy-db/hub/cargo";
731
- import { extractPartition as extractPartition2 } from "@noy-db/hub/bundle";
1124
+ import { extractPartition as extractPartition2 } from "@noy-db/hub/cargo";
732
1125
  async function proposeSurface(smv, def, proposedBy, now) {
733
1126
  const row = {
734
1127
  ...def,
@@ -1206,85 +1599,6 @@ var init_cross_vault_live = __esm({
1206
1599
  }
1207
1600
  });
1208
1601
 
1209
- // src/federation/insight-auto-push.ts
1210
- var InsightAutoPush;
1211
- var init_insight_auto_push = __esm({
1212
- "src/federation/insight-auto-push.ts"() {
1213
- "use strict";
1214
- InsightAutoPush = class {
1215
- constructor(recompute, isSource, onError = (err, pk) => console.warn(`[klum-db] insight auto-push failed for shard "${pk}":`, err), debounceMs) {
1216
- this.recompute = recompute;
1217
- this.isSource = isSource;
1218
- this.onError = onError;
1219
- this.debounceMs = debounceMs;
1220
- }
1221
- recompute;
1222
- isSource;
1223
- onError;
1224
- debounceMs;
1225
- dirty = /* @__PURE__ */ new Set();
1226
- pending = null;
1227
- timer = null;
1228
- resolvePending = null;
1229
- flushing = false;
1230
- /** Called from a shard's onAfterWrite hook. Marks the shard dirty + schedules a flush. */
1231
- noteWrite(partitionKey, collection) {
1232
- if (!this.isSource(collection)) return;
1233
- this.dirty.add(partitionKey);
1234
- if (this.debounceMs !== void 0) this.scheduleDebounced();
1235
- else this.schedule();
1236
- }
1237
- /** Resolve once no flush is pending (drains rescheduled flushes too). */
1238
- async whenSettled() {
1239
- while (this.pending) await this.pending;
1240
- }
1241
- // ── microtask path (unchanged default) ──
1242
- schedule() {
1243
- if (this.pending) return;
1244
- this.pending = this.runFlush().finally(() => {
1245
- this.pending = null;
1246
- if (this.dirty.size > 0) this.schedule();
1247
- });
1248
- }
1249
- // ── debounce path (#13) ──
1250
- scheduleDebounced() {
1251
- if (!this.pending) this.pending = new Promise((r) => {
1252
- this.resolvePending = r;
1253
- });
1254
- if (this.timer) clearTimeout(this.timer);
1255
- this.timer = setTimeout(() => {
1256
- this.timer = null;
1257
- if (this.flushing) return;
1258
- this.flushing = true;
1259
- void this.runFlush().finally(() => {
1260
- this.flushing = false;
1261
- if (this.dirty.size > 0) {
1262
- this.scheduleDebounced();
1263
- } else {
1264
- const resolve = this.resolvePending;
1265
- this.resolvePending = null;
1266
- this.pending = null;
1267
- resolve?.();
1268
- }
1269
- });
1270
- }, this.debounceMs);
1271
- }
1272
- async runFlush() {
1273
- await Promise.resolve();
1274
- const pks = [...this.dirty];
1275
- this.dirty.clear();
1276
- for (const pk of pks) {
1277
- try {
1278
- await this.recompute(pk);
1279
- } catch (err) {
1280
- this.onError(err, pk);
1281
- }
1282
- }
1283
- }
1284
- };
1285
- }
1286
- });
1287
-
1288
1602
  // src/federation/partial-reduce.ts
1289
1603
  function canPartialReduce(spec) {
1290
1604
  return Object.values(spec).every((r) => typeof r.merge === "function");
@@ -1508,25 +1822,25 @@ __export(vault_group_exports, {
1508
1822
  ShardedQuery: () => ShardedQuery,
1509
1823
  VaultGroup: () => VaultGroup
1510
1824
  });
1511
- import { CrossShardJoinError, DataResidencyError, ReservedVaultNameError, ShardProvisioningError, UnknownShardError, ValidationError } from "@noy-db/hub/cargo";
1825
+ import { CrossShardJoinError, DataResidencyError, ReservedVaultNameError, ShardProvisioningError, UnknownShardError, ValidationError as ValidationError2 } from "@noy-db/hub/cargo";
1512
1826
  function autoPushConfig(spec) {
1513
1827
  if (!spec.autoPush) return null;
1514
1828
  return spec.autoPush === true ? {} : spec.autoPush;
1515
1829
  }
1516
1830
  function assertSafePartitionKey(partitionKey) {
1517
1831
  if (partitionKey.length === 0) {
1518
- throw new ValidationError("partitionKey must be a non-empty string");
1832
+ throw new ValidationError2("partitionKey must be a non-empty string");
1519
1833
  }
1520
1834
  if (partitionKey === STATE_VAULT_NAME) {
1521
1835
  throw new ReservedVaultNameError(partitionKey);
1522
1836
  }
1523
1837
  if (!SAFE_PARTITION_KEY.test(partitionKey)) {
1524
- throw new ValidationError(
1838
+ throw new ValidationError2(
1525
1839
  `partitionKey "${partitionKey}" contains characters outside [A-Za-z0-9._-]. Map your records to a store-safe key in sharding.keyOf.`
1526
1840
  );
1527
1841
  }
1528
1842
  if (partitionKey.includes(SHARD_SEPARATOR)) {
1529
- throw new ValidationError(
1843
+ throw new ValidationError2(
1530
1844
  `partitionKey "${partitionKey}" must not contain "--" \u2014 it is reserved as the shard vault-id separator and would risk shard-id collisions.`
1531
1845
  );
1532
1846
  }
@@ -1538,6 +1852,7 @@ var init_vault_group = __esm({
1538
1852
  init_state_vault();
1539
1853
  init_constants();
1540
1854
  init_classify_skip();
1855
+ init_read_model();
1541
1856
  init_cross_shard_join();
1542
1857
  init_cross_vault_live();
1543
1858
  init_insight_auto_push();
@@ -1556,7 +1871,7 @@ var init_vault_group = __esm({
1556
1871
  this.cutoverOnOpen = cutoverOnOpen;
1557
1872
  this.meta = meta;
1558
1873
  if (name.includes(SHARD_SEPARATOR)) {
1559
- throw new ValidationError(
1874
+ throw new ValidationError2(
1560
1875
  `VaultGroup name "${name}" must not contain "--" (reserved shard vault-id separator).`
1561
1876
  );
1562
1877
  }
@@ -1770,7 +2085,7 @@ var init_vault_group = __esm({
1770
2085
  withCrossVaultDerivation(spec) {
1771
2086
  const target = spec.target.vault;
1772
2087
  if (target === this.name || target.startsWith(`${this.name}${SHARD_SEPARATOR}`)) {
1773
- throw new ValidationError(
2088
+ throw new ValidationError2(
1774
2089
  `withCrossVaultDerivation: target.vault "${target}" is the "${this.name}" group itself or one of its shards \u2014 an Insight summary must target a SEPARATE analytics vault, never write back into client-shard data (it would breach the per-shard DEK boundary). Use a distinct vault name.`
1775
2090
  );
1776
2091
  }
@@ -1830,12 +2145,7 @@ var init_vault_group = __esm({
1830
2145
  skipped.push({ vaultId: row.vaultId, reason: "error", ...res?.error ? { error: res.error } : {} });
1831
2146
  continue;
1832
2147
  }
1833
- const ctx = {
1834
- vaultId: row.vaultId,
1835
- partitionKey: row.partitionKey,
1836
- schemaVersion: row.schemaVersion
1837
- };
1838
- const summary = spec.derive(res.result, ctx);
2148
+ const summary = deriveShardSummary(spec, res.result, row);
1839
2149
  await out.put(row.partitionKey, summary);
1840
2150
  written++;
1841
2151
  }
@@ -1855,17 +2165,12 @@ var init_vault_group = __esm({
1855
2165
  const row = await this.registry.get(this.registryId(partitionKey));
1856
2166
  if (!row) return;
1857
2167
  const shard = await this.openShard(partitionKey);
1858
- const ctx = {
1859
- vaultId: row.vaultId,
1860
- partitionKey,
1861
- schemaVersion: row.schemaVersion
1862
- };
1863
2168
  for (const spec of this.crossVaultDerivations) {
1864
2169
  if (!spec.autoPush) continue;
1865
2170
  const min = autoPushConfig(spec)?.minVersion;
1866
2171
  if (min !== void 0 && row.schemaVersion < min) continue;
1867
2172
  const records = await shard.collection(spec.source).list();
1868
- const summary = spec.derive(records, ctx);
2173
+ const summary = deriveShardSummary(spec, records, row);
1869
2174
  const insight = await this.db.openVault(spec.target.vault);
1870
2175
  await insight.collection(spec.target.collection).put(partitionKey, summary);
1871
2176
  }
@@ -2224,8 +2529,111 @@ var init_vault_group = __esm({
2224
2529
  }
2225
2530
  });
2226
2531
 
2532
+ // src/portal/provision.ts
2533
+ var provision_exports = {};
2534
+ __export(provision_exports, {
2535
+ provisionPortal: () => provisionPortal,
2536
+ revokePortalInvite: () => revokePortalInvite
2537
+ });
2538
+ import { generateULID as generateULID5 } from "@noy-db/hub/cargo";
2539
+ async function requireRow(group, client) {
2540
+ const row = await group.registry.get(group.registryId(client));
2541
+ if (!row) throw new Error(`provisionPortal: client "${client}" has no registry row in group.`);
2542
+ return row;
2543
+ }
2544
+ async function provisionPortal(db, group, opts) {
2545
+ const existing = await group.registry.get(group.registryId(opts.client));
2546
+ if (!existing) await group.createShard(opts.client);
2547
+ const row = existing ?? await requireRow(group, opts.client);
2548
+ const vaultId = row.vaultId;
2549
+ for (const g of opts.firmGrants ?? []) {
2550
+ await db.grant(vaultId, {
2551
+ userId: g.userId,
2552
+ displayName: g.displayName,
2553
+ role: g.role,
2554
+ passphrase: g.passphrase
2555
+ });
2556
+ }
2557
+ const { issueInvite, issuePeerRecovery } = await import("@noy-db/on-magic-link");
2558
+ const mode = opts.mode ?? "invite";
2559
+ const issued = mode === "rebind" ? await issuePeerRecovery(db, vaultId, {
2560
+ userId: opts.invite.userId,
2561
+ displayName: opts.invite.displayName,
2562
+ ...opts.invite.role !== void 0 ? { role: opts.invite.role } : {},
2563
+ ...opts.invite.ttlMs !== void 0 ? { ttlMs: opts.invite.ttlMs } : {},
2564
+ ...opts.invite.tempPhrase !== void 0 ? { tempPhrase: opts.invite.tempPhrase } : {}
2565
+ }) : await issueInvite(db, vaultId, {
2566
+ userId: opts.invite.userId,
2567
+ displayName: opts.invite.displayName,
2568
+ role: opts.invite.role ?? "client",
2569
+ ...opts.invite.ttlMs !== void 0 ? { ttlMs: opts.invite.ttlMs } : {},
2570
+ ...opts.invite.tempPhrase !== void 0 ? { tempPhrase: opts.invite.tempPhrase } : {}
2571
+ });
2572
+ const auditRef = {
2573
+ tokenId: issued.payload.tokenId,
2574
+ userId: opts.invite.userId,
2575
+ kind: mode,
2576
+ issuedAt: Date.now(),
2577
+ expiresAt: issued.payload.expiresAt
2578
+ };
2579
+ const handle = row.handle ?? generateULID5();
2580
+ const updated = {
2581
+ ...row,
2582
+ handle,
2583
+ portal: {
2584
+ enabledAt: row.portal?.enabledAt ?? Date.now(),
2585
+ invites: [...row.portal?.invites ?? [], auditRef]
2586
+ }
2587
+ };
2588
+ await group.registry.put(group.registryId(opts.client), updated);
2589
+ return {
2590
+ vaultId,
2591
+ handle,
2592
+ invite: {
2593
+ encoded: issued.encoded,
2594
+ tokenId: issued.payload.tokenId,
2595
+ userId: opts.invite.userId,
2596
+ expiresAt: issued.payload.expiresAt
2597
+ },
2598
+ row: updated
2599
+ };
2600
+ }
2601
+ async function revokePortalInvite(db, group, opts) {
2602
+ const row = await requireRow(group, opts.client);
2603
+ const invites = row.portal?.invites ?? [];
2604
+ const ref = invites.find((i) => i.tokenId === opts.tokenId);
2605
+ if (!ref) {
2606
+ throw new Error(`revokePortalInvite: no invite with tokenId "${opts.tokenId}" for client "${opts.client}".`);
2607
+ }
2608
+ if (ref.revokedAt !== void 0) return row;
2609
+ const { revokeInvite } = await import("@noy-db/on-magic-link");
2610
+ await revokeInvite(db, row.vaultId, {
2611
+ tokenId: ref.tokenId,
2612
+ vault: row.vaultId,
2613
+ userId: ref.userId,
2614
+ kind: ref.kind === "rebind" ? "peer-recovery" : "invite",
2615
+ issuer: "",
2616
+ tempPhrase: "",
2617
+ expiresAt: ref.expiresAt
2618
+ });
2619
+ const updated = {
2620
+ ...row,
2621
+ portal: {
2622
+ enabledAt: row.portal?.enabledAt ?? Date.now(),
2623
+ invites: invites.map((i) => i.tokenId === opts.tokenId ? { ...i, revokedAt: Date.now() } : i)
2624
+ }
2625
+ };
2626
+ await group.registry.put(group.registryId(opts.client), updated);
2627
+ return updated;
2628
+ }
2629
+ var init_provision = __esm({
2630
+ "src/portal/provision.ts"() {
2631
+ "use strict";
2632
+ }
2633
+ });
2634
+
2227
2635
  // src/index.ts
2228
- import { ValidationError as ValidationError2, ReservedVaultNameError as ReservedVaultNameError2, VaultTemplateNotFoundError, STATE_VAULT_NAME as STATE_VAULT_NAME2 } from "@noy-db/hub/cargo";
2636
+ import { ValidationError as ValidationError3, ReservedVaultNameError as ReservedVaultNameError2, VaultTemplateNotFoundError, STATE_VAULT_NAME as STATE_VAULT_NAME2 } from "@noy-db/hub/cargo";
2229
2637
 
2230
2638
  // src/dock/docked-unit.ts
2231
2639
  var DockedUnit = class {
@@ -2244,6 +2652,10 @@ var DockedUnit = class {
2244
2652
  }
2245
2653
  };
2246
2654
 
2655
+ // src/index.ts
2656
+ init_share_link();
2657
+ init_read_model();
2658
+
2247
2659
  // src/federation/group-inspector.ts
2248
2660
  function groupInspector(group) {
2249
2661
  let shardIds = /* @__PURE__ */ new Set();
@@ -2323,7 +2735,7 @@ import {
2323
2735
  init_merge_compartment();
2324
2736
  init_stage_records();
2325
2737
  init_stage_records();
2326
- import { decryptExtractedPartition as decryptExtractedPartition2 } from "@noy-db/hub/bundle";
2738
+ import { decryptExtractedPartition as decryptExtractedPartition2 } from "@noy-db/hub/cargo";
2327
2739
  var MinVersionError = class extends Error {
2328
2740
  constructor(fromVersion, toVersion) {
2329
2741
  super(
@@ -2402,7 +2814,7 @@ var Lobby = class {
2402
2814
  }
2403
2815
  async openVaultGroup(name, opts) {
2404
2816
  const db = this.noydb;
2405
- if (db.isClosed) throw new ValidationError2("Instance is closed");
2817
+ if (db.isClosed) throw new ValidationError3("Instance is closed");
2406
2818
  if (name === STATE_VAULT_NAME2) throw new ReservedVaultNameError2(name);
2407
2819
  const template = this.vaultTemplates.get(opts.sharding.vaultTemplate);
2408
2820
  if (!template) throw new VaultTemplateNotFoundError(opts.sharding.vaultTemplate);
@@ -2424,7 +2836,7 @@ var Lobby = class {
2424
2836
  }
2425
2837
  async openStateManagementVault() {
2426
2838
  const db = this.noydb;
2427
- if (db.isClosed) throw new ValidationError2("Instance is closed");
2839
+ if (db.isClosed) throw new ValidationError3("Instance is closed");
2428
2840
  const { StateManagementVault: StateManagementVault2 } = await Promise.resolve().then(() => (init_state_vault(), state_vault_exports));
2429
2841
  return StateManagementVault2.open(db);
2430
2842
  }
@@ -2444,6 +2856,64 @@ var Lobby = class {
2444
2856
  const { graduate: graduateFn } = await Promise.resolve().then(() => (init_graduate(), graduate_exports));
2445
2857
  return graduateFn(this.noydb, docked, opts);
2446
2858
  }
2859
+ // ─── Portal deep-link addressing (#43) ────────────────────────────────────
2860
+ /**
2861
+ * Resolve a portal share link (raw URL/path or a parsed `ShareLink`)
2862
+ * against the fleet registry: `vaultHandle → registry row` plus a
2863
+ * console-route descriptor the firm app turns into its own URL.
2864
+ * Unknown/foreign handles fail closed with `ShareLinkResolutionError`.
2865
+ * Delegates to `resolveShareLinkAgainstRegistry` in `portal/share-link.ts`.
2866
+ */
2867
+ async resolveShareLink(link, opts) {
2868
+ const { resolveShareLinkAgainstRegistry: resolveShareLinkAgainstRegistry2 } = await Promise.resolve().then(() => (init_share_link(), share_link_exports));
2869
+ return resolveShareLinkAgainstRegistry2(link, opts.registry);
2870
+ }
2871
+ /**
2872
+ * Mint a portal share link for a fleet client (`client` partition key +
2873
+ * `group`), delegating the link grammar to `@noy-db/hub/share-link`.
2874
+ * Fails closed when the client is unknown or has no portal handle.
2875
+ * Delegates to `buildShareLinkForClient` in `portal/share-link.ts`.
2876
+ */
2877
+ async buildShareLink(parts, opts) {
2878
+ const { buildShareLinkForClient: buildShareLinkForClient2 } = await Promise.resolve().then(() => (init_share_link(), share_link_exports));
2879
+ return buildShareLinkForClient2(parts, opts.registry);
2880
+ }
2881
+ // ─── Portal provisioning (#42) ────────────────────────────────────────────
2882
+ /**
2883
+ * Provision (or re-invite, `mode: 'rebind'`) a client portal vault in
2884
+ * one audited operation: ensure the shard, assign the portal handle
2885
+ * (#43's link target), apply firm grants, issue the magic-link invite,
2886
+ * stamp the audit trail on the fleet registry row.
2887
+ * Needs `@noy-db/on-magic-link` (optional peer) and a session created
2888
+ * with `teamStrategy: withTeam()`.
2889
+ * Delegates to `provisionPortal` in `portal/provision.ts`.
2890
+ */
2891
+ async provisionPortal(group, opts) {
2892
+ const { provisionPortal: provisionPortalFn } = await Promise.resolve().then(() => (init_provision(), provision_exports));
2893
+ return provisionPortalFn(this.noydb, group, opts);
2894
+ }
2895
+ /**
2896
+ * Revoke an outstanding portal invite and stamp `revokedAt` on the
2897
+ * fleet audit trail. Idempotent; fails closed on an unknown tokenId.
2898
+ * Delegates to `revokePortalInvite` in `portal/provision.ts`.
2899
+ */
2900
+ async revokePortalInvite(group, opts) {
2901
+ const { revokePortalInvite: revokePortalInviteFn } = await Promise.resolve().then(() => (init_provision(), provision_exports));
2902
+ return revokePortalInviteFn(this.noydb, group, opts);
2903
+ }
2904
+ // ─── Federated read-model (#44) ───────────────────────────────────────────
2905
+ /**
2906
+ * Open a maintained federated read-model over `group` (#44 S1): rollup
2907
+ * models reduce each shard's `source` into one summary row per shard in
2908
+ * the read-model vault — deterministic ids, `_shard`/`_sourceVersion`
2909
+ * provenance, fail-closed `posture.surface` allowlist, explicit
2910
+ * `refresh()`. Spec: docs/federated-read-model.md.
2911
+ * Delegates to `openReadModel` in `federation/read-model.ts`.
2912
+ */
2913
+ async openReadModel(group, opts) {
2914
+ const { openReadModel: openReadModelFn } = await Promise.resolve().then(() => (init_read_model(), read_model_exports));
2915
+ return openReadModelFn(group, opts);
2916
+ }
2447
2917
  // ─── FR-7 Surface API ─────────────────────────────────────────────────────
2448
2918
  /**
2449
2919
  * Export a scoped partition from `vaultName` bounded to the given surface.
@@ -2525,8 +2995,10 @@ export {
2525
2995
  NOYDB_MULTI_BUNDLE_MAGIC,
2526
2996
  NOYDB_MULTI_BUNDLE_PREFIX_BYTES,
2527
2997
  NOYDB_MULTI_BUNDLE_VERSION,
2998
+ PostureViolationError,
2528
2999
  ReservedVaultNameError3 as ReservedVaultNameError,
2529
3000
  ShardProvisioningError2 as ShardProvisioningError,
3001
+ ShareLinkResolutionError,
2530
3002
  SurfaceCadenceScheduler,
2531
3003
  SurfaceNotFoundError,
2532
3004
  SurfaceStateError,