@klum-db/lobby 0.3.0-pre.1 → 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;
@@ -28,12 +421,10 @@ var init_uint32 = __esm({
28
421
  import { sha256Hex, generateULID } from "@noy-db/hub/cargo";
29
422
  import {
30
423
  writePod,
31
- readPodHeader
424
+ readPodHeader,
425
+ readPodCover,
426
+ NOYDB_BUNDLE_MAGIC
32
427
  } from "@noy-db/hub/pod";
33
- import {
34
- readNoydbBundlePublicEnvelope,
35
- hasNoydbBundleMagic
36
- } from "@noy-db/hub";
37
428
  function encodeMultiBundle(manifest, inner) {
38
429
  validateManifest(manifest);
39
430
  if (manifest.compartments.length !== inner.length) {
@@ -59,6 +450,11 @@ function encodeMultiBundle(manifest, inner) {
59
450
  }
60
451
  return out;
61
452
  }
453
+ function hasPodMagic(bytes) {
454
+ if (bytes.length < NOYDB_BUNDLE_MAGIC.length) return false;
455
+ for (let i = 0; i < NOYDB_BUNDLE_MAGIC.length; i++) if (bytes[i] !== NOYDB_BUNDLE_MAGIC[i]) return false;
456
+ return true;
457
+ }
62
458
  function hasMultiMagic(bytes) {
63
459
  if (bytes.length < NOYDB_MULTI_BUNDLE_MAGIC.length) return false;
64
460
  for (let i = 0; i < NOYDB_MULTI_BUNDLE_MAGIC.length; i++) if (bytes[i] !== NOYDB_MULTI_BUNDLE_MAGIC[i]) return false;
@@ -135,7 +531,7 @@ async function writeMultiVaultBundle(compartments, opts = {}) {
135
531
  );
136
532
  }
137
533
  if (c.disclose?.publicEnvelope === true) {
138
- const env = readNoydbBundlePublicEnvelope(innerBytes);
534
+ const env = readPodCover(innerBytes);
139
535
  if (env !== void 0) entry.publicEnvelope = env;
140
536
  }
141
537
  const fence = await c.vault.schemaFenceState();
@@ -152,9 +548,9 @@ async function writeMultiVaultBundle(compartments, opts = {}) {
152
548
  }
153
549
  async function readNoydbBundleManifest(bytes) {
154
550
  if (hasMultiMagic(bytes)) return [...decodeMultiBundle(bytes).manifest.compartments];
155
- if (hasNoydbBundleMagic(bytes)) {
551
+ if (hasPodMagic(bytes)) {
156
552
  const header = readPodHeader(bytes);
157
- const env = readNoydbBundlePublicEnvelope(bytes);
553
+ const env = readPodCover(bytes);
158
554
  const entry = {
159
555
  handle: header.handle,
160
556
  innerBytes: bytes.length,
@@ -169,7 +565,7 @@ function readMultiVaultBundleCompartment(bytes, selector) {
169
565
  if (typeof selector === "number" && !Number.isInteger(selector)) {
170
566
  throw new Error(`readMultiVaultBundleCompartment: numeric selector must be an integer, got ${selector}.`);
171
567
  }
172
- if (hasNoydbBundleMagic(bytes) && !hasMultiMagic(bytes)) {
568
+ if (hasPodMagic(bytes) && !hasMultiMagic(bytes)) {
173
569
  const header = readPodHeader(bytes);
174
570
  if (selector === 0 || selector === header.handle) return bytes;
175
571
  throw new Error(`readMultiVaultBundleCompartment: single v1 bundle has only compartment "${header.handle}".`);
@@ -202,7 +598,7 @@ import {
202
598
  walkClosure,
203
599
  extractPartition,
204
600
  describeExtraction
205
- } from "@noy-db/hub/bundle";
601
+ } from "@noy-db/hub/cargo";
206
602
  import { readPodHeader as readPodHeader2 } from "@noy-db/hub/pod";
207
603
  import { sha256Hex as sha256Hex2, generateULID as generateULID2 } from "@noy-db/hub/cargo";
208
604
  function asIdArray(v) {
@@ -430,7 +826,7 @@ var init_field_authority = __esm({
430
826
 
431
827
  // src/interchange/merge-compartment.ts
432
828
  import { diffVault } from "@noy-db/hub/cargo";
433
- import { decryptExtractedPartition } from "@noy-db/hub/bundle";
829
+ import { decryptExtractedPartition } from "@noy-db/hub/cargo";
434
830
  function strategyFor(opts, collection) {
435
831
  if (typeof opts === "string") return opts;
436
832
  return opts[collection] ?? opts.default ?? "manual-queue";
@@ -725,7 +1121,7 @@ __export(surface_exports, {
725
1121
  proposeSurface: () => proposeSurface
726
1122
  });
727
1123
  import { generateULID as generateULID3 } from "@noy-db/hub/cargo";
728
- import { extractPartition as extractPartition2 } from "@noy-db/hub/bundle";
1124
+ import { extractPartition as extractPartition2 } from "@noy-db/hub/cargo";
729
1125
  async function proposeSurface(smv, def, proposedBy, now) {
730
1126
  const row = {
731
1127
  ...def,
@@ -1203,85 +1599,6 @@ var init_cross_vault_live = __esm({
1203
1599
  }
1204
1600
  });
1205
1601
 
1206
- // src/federation/insight-auto-push.ts
1207
- var InsightAutoPush;
1208
- var init_insight_auto_push = __esm({
1209
- "src/federation/insight-auto-push.ts"() {
1210
- "use strict";
1211
- InsightAutoPush = class {
1212
- constructor(recompute, isSource, onError = (err, pk) => console.warn(`[klum-db] insight auto-push failed for shard "${pk}":`, err), debounceMs) {
1213
- this.recompute = recompute;
1214
- this.isSource = isSource;
1215
- this.onError = onError;
1216
- this.debounceMs = debounceMs;
1217
- }
1218
- recompute;
1219
- isSource;
1220
- onError;
1221
- debounceMs;
1222
- dirty = /* @__PURE__ */ new Set();
1223
- pending = null;
1224
- timer = null;
1225
- resolvePending = null;
1226
- flushing = false;
1227
- /** Called from a shard's onAfterWrite hook. Marks the shard dirty + schedules a flush. */
1228
- noteWrite(partitionKey, collection) {
1229
- if (!this.isSource(collection)) return;
1230
- this.dirty.add(partitionKey);
1231
- if (this.debounceMs !== void 0) this.scheduleDebounced();
1232
- else this.schedule();
1233
- }
1234
- /** Resolve once no flush is pending (drains rescheduled flushes too). */
1235
- async whenSettled() {
1236
- while (this.pending) await this.pending;
1237
- }
1238
- // ── microtask path (unchanged default) ──
1239
- schedule() {
1240
- if (this.pending) return;
1241
- this.pending = this.runFlush().finally(() => {
1242
- this.pending = null;
1243
- if (this.dirty.size > 0) this.schedule();
1244
- });
1245
- }
1246
- // ── debounce path (#13) ──
1247
- scheduleDebounced() {
1248
- if (!this.pending) this.pending = new Promise((r) => {
1249
- this.resolvePending = r;
1250
- });
1251
- if (this.timer) clearTimeout(this.timer);
1252
- this.timer = setTimeout(() => {
1253
- this.timer = null;
1254
- if (this.flushing) return;
1255
- this.flushing = true;
1256
- void this.runFlush().finally(() => {
1257
- this.flushing = false;
1258
- if (this.dirty.size > 0) {
1259
- this.scheduleDebounced();
1260
- } else {
1261
- const resolve = this.resolvePending;
1262
- this.resolvePending = null;
1263
- this.pending = null;
1264
- resolve?.();
1265
- }
1266
- });
1267
- }, this.debounceMs);
1268
- }
1269
- async runFlush() {
1270
- await Promise.resolve();
1271
- const pks = [...this.dirty];
1272
- this.dirty.clear();
1273
- for (const pk of pks) {
1274
- try {
1275
- await this.recompute(pk);
1276
- } catch (err) {
1277
- this.onError(err, pk);
1278
- }
1279
- }
1280
- }
1281
- };
1282
- }
1283
- });
1284
-
1285
1602
  // src/federation/partial-reduce.ts
1286
1603
  function canPartialReduce(spec) {
1287
1604
  return Object.values(spec).every((r) => typeof r.merge === "function");
@@ -1505,25 +1822,25 @@ __export(vault_group_exports, {
1505
1822
  ShardedQuery: () => ShardedQuery,
1506
1823
  VaultGroup: () => VaultGroup
1507
1824
  });
1508
- 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";
1509
1826
  function autoPushConfig(spec) {
1510
1827
  if (!spec.autoPush) return null;
1511
1828
  return spec.autoPush === true ? {} : spec.autoPush;
1512
1829
  }
1513
1830
  function assertSafePartitionKey(partitionKey) {
1514
1831
  if (partitionKey.length === 0) {
1515
- throw new ValidationError("partitionKey must be a non-empty string");
1832
+ throw new ValidationError2("partitionKey must be a non-empty string");
1516
1833
  }
1517
1834
  if (partitionKey === STATE_VAULT_NAME) {
1518
1835
  throw new ReservedVaultNameError(partitionKey);
1519
1836
  }
1520
1837
  if (!SAFE_PARTITION_KEY.test(partitionKey)) {
1521
- throw new ValidationError(
1838
+ throw new ValidationError2(
1522
1839
  `partitionKey "${partitionKey}" contains characters outside [A-Za-z0-9._-]. Map your records to a store-safe key in sharding.keyOf.`
1523
1840
  );
1524
1841
  }
1525
1842
  if (partitionKey.includes(SHARD_SEPARATOR)) {
1526
- throw new ValidationError(
1843
+ throw new ValidationError2(
1527
1844
  `partitionKey "${partitionKey}" must not contain "--" \u2014 it is reserved as the shard vault-id separator and would risk shard-id collisions.`
1528
1845
  );
1529
1846
  }
@@ -1535,6 +1852,7 @@ var init_vault_group = __esm({
1535
1852
  init_state_vault();
1536
1853
  init_constants();
1537
1854
  init_classify_skip();
1855
+ init_read_model();
1538
1856
  init_cross_shard_join();
1539
1857
  init_cross_vault_live();
1540
1858
  init_insight_auto_push();
@@ -1553,7 +1871,7 @@ var init_vault_group = __esm({
1553
1871
  this.cutoverOnOpen = cutoverOnOpen;
1554
1872
  this.meta = meta;
1555
1873
  if (name.includes(SHARD_SEPARATOR)) {
1556
- throw new ValidationError(
1874
+ throw new ValidationError2(
1557
1875
  `VaultGroup name "${name}" must not contain "--" (reserved shard vault-id separator).`
1558
1876
  );
1559
1877
  }
@@ -1767,7 +2085,7 @@ var init_vault_group = __esm({
1767
2085
  withCrossVaultDerivation(spec) {
1768
2086
  const target = spec.target.vault;
1769
2087
  if (target === this.name || target.startsWith(`${this.name}${SHARD_SEPARATOR}`)) {
1770
- throw new ValidationError(
2088
+ throw new ValidationError2(
1771
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.`
1772
2090
  );
1773
2091
  }
@@ -1827,12 +2145,7 @@ var init_vault_group = __esm({
1827
2145
  skipped.push({ vaultId: row.vaultId, reason: "error", ...res?.error ? { error: res.error } : {} });
1828
2146
  continue;
1829
2147
  }
1830
- const ctx = {
1831
- vaultId: row.vaultId,
1832
- partitionKey: row.partitionKey,
1833
- schemaVersion: row.schemaVersion
1834
- };
1835
- const summary = spec.derive(res.result, ctx);
2148
+ const summary = deriveShardSummary(spec, res.result, row);
1836
2149
  await out.put(row.partitionKey, summary);
1837
2150
  written++;
1838
2151
  }
@@ -1852,17 +2165,12 @@ var init_vault_group = __esm({
1852
2165
  const row = await this.registry.get(this.registryId(partitionKey));
1853
2166
  if (!row) return;
1854
2167
  const shard = await this.openShard(partitionKey);
1855
- const ctx = {
1856
- vaultId: row.vaultId,
1857
- partitionKey,
1858
- schemaVersion: row.schemaVersion
1859
- };
1860
2168
  for (const spec of this.crossVaultDerivations) {
1861
2169
  if (!spec.autoPush) continue;
1862
2170
  const min = autoPushConfig(spec)?.minVersion;
1863
2171
  if (min !== void 0 && row.schemaVersion < min) continue;
1864
2172
  const records = await shard.collection(spec.source).list();
1865
- const summary = spec.derive(records, ctx);
2173
+ const summary = deriveShardSummary(spec, records, row);
1866
2174
  const insight = await this.db.openVault(spec.target.vault);
1867
2175
  await insight.collection(spec.target.collection).put(partitionKey, summary);
1868
2176
  }
@@ -2221,8 +2529,111 @@ var init_vault_group = __esm({
2221
2529
  }
2222
2530
  });
2223
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
+
2224
2635
  // src/index.ts
2225
- 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";
2226
2637
 
2227
2638
  // src/dock/docked-unit.ts
2228
2639
  var DockedUnit = class {
@@ -2241,6 +2652,10 @@ var DockedUnit = class {
2241
2652
  }
2242
2653
  };
2243
2654
 
2655
+ // src/index.ts
2656
+ init_share_link();
2657
+ init_read_model();
2658
+
2244
2659
  // src/federation/group-inspector.ts
2245
2660
  function groupInspector(group) {
2246
2661
  let shardIds = /* @__PURE__ */ new Set();
@@ -2320,7 +2735,7 @@ import {
2320
2735
  init_merge_compartment();
2321
2736
  init_stage_records();
2322
2737
  init_stage_records();
2323
- import { decryptExtractedPartition as decryptExtractedPartition2 } from "@noy-db/hub/bundle";
2738
+ import { decryptExtractedPartition as decryptExtractedPartition2 } from "@noy-db/hub/cargo";
2324
2739
  var MinVersionError = class extends Error {
2325
2740
  constructor(fromVersion, toVersion) {
2326
2741
  super(
@@ -2399,7 +2814,7 @@ var Lobby = class {
2399
2814
  }
2400
2815
  async openVaultGroup(name, opts) {
2401
2816
  const db = this.noydb;
2402
- if (db.isClosed) throw new ValidationError2("Instance is closed");
2817
+ if (db.isClosed) throw new ValidationError3("Instance is closed");
2403
2818
  if (name === STATE_VAULT_NAME2) throw new ReservedVaultNameError2(name);
2404
2819
  const template = this.vaultTemplates.get(opts.sharding.vaultTemplate);
2405
2820
  if (!template) throw new VaultTemplateNotFoundError(opts.sharding.vaultTemplate);
@@ -2421,7 +2836,7 @@ var Lobby = class {
2421
2836
  }
2422
2837
  async openStateManagementVault() {
2423
2838
  const db = this.noydb;
2424
- if (db.isClosed) throw new ValidationError2("Instance is closed");
2839
+ if (db.isClosed) throw new ValidationError3("Instance is closed");
2425
2840
  const { StateManagementVault: StateManagementVault2 } = await Promise.resolve().then(() => (init_state_vault(), state_vault_exports));
2426
2841
  return StateManagementVault2.open(db);
2427
2842
  }
@@ -2441,6 +2856,64 @@ var Lobby = class {
2441
2856
  const { graduate: graduateFn } = await Promise.resolve().then(() => (init_graduate(), graduate_exports));
2442
2857
  return graduateFn(this.noydb, docked, opts);
2443
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
+ }
2444
2917
  // ─── FR-7 Surface API ─────────────────────────────────────────────────────
2445
2918
  /**
2446
2919
  * Export a scoped partition from `vaultName` bounded to the given surface.
@@ -2522,8 +2995,10 @@ export {
2522
2995
  NOYDB_MULTI_BUNDLE_MAGIC,
2523
2996
  NOYDB_MULTI_BUNDLE_PREFIX_BYTES,
2524
2997
  NOYDB_MULTI_BUNDLE_VERSION,
2998
+ PostureViolationError,
2525
2999
  ReservedVaultNameError3 as ReservedVaultNameError,
2526
3000
  ShardProvisioningError2 as ShardProvisioningError,
3001
+ ShareLinkResolutionError,
2527
3002
  SurfaceCadenceScheduler,
2528
3003
  SurfaceNotFoundError,
2529
3004
  SurfaceStateError,