@lunora/do 1.0.0-alpha.30 → 1.0.0-alpha.32

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.
@@ -2,7 +2,7 @@ import { LunoraError, toErrorBody } from '@lunora/errors';
2
2
  import { drizzle } from 'drizzle-orm/durable-sqlite';
3
3
  import { c as constantTimeEqual } from './constant-time-equal-BVRWZgES.mjs';
4
4
  import { j as jsonResponse } from './json-response-BdbtpOhm.mjs';
5
- import { e as encodeWire, a as awaitWsDrain, t as trySendFrame, d as decodeWire, s as subscriptionListDeltas, b as sendDeltaFrames } from './subscription-delivery-CWigSEr3.mjs';
5
+ import { e as encodeWire, d as decodeWire } from './wire-codec-CzQc1pvf.mjs';
6
6
  import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-Dy3oFZ26.mjs';
7
7
  import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
8
8
  import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs';
@@ -12,19 +12,84 @@ import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX,
12
12
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
13
13
  import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
14
14
  import { readBookmark, armRestore } from './armRestore-4Px61hHS.mjs';
15
- import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-BYlSGY0N.mjs';
16
- import { stableStringify } from './stableStringify-MydiuScU.mjs';
15
+ import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-DnSvbjil.mjs';
16
+ import { stableWireKey } from './stableWireKey-DKuXO7T5.mjs';
17
+ import { awaitWsDrain, trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-CT76bYny.mjs';
17
18
  import { fingerprintError } from '@lunora/fingerprint';
18
19
  import { redact, standardRules } from '@visulima/redact';
19
20
  import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
20
- import { runReadonlySql } from './MAX_SQL_ROWS-D57CJaT9.mjs';
21
+ import { runReadonlySql } from './MAX_SQL_ROWS-iFAA8FbD.mjs';
21
22
  import { ConflictError } from './ConflictError-CLoq37xH.mjs';
22
23
  import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
23
24
  import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
25
+ import { stableStringify } from './stableStringify-mC40mZts.mjs';
24
26
  import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
25
27
 
26
28
  const MAX_BATCH_ENTRIES = 500;
27
29
 
30
+ const evictOldestEntry = (map, capacity) => {
31
+ if (map.size < capacity) {
32
+ return;
33
+ }
34
+ const oldest = map.keys().next().value;
35
+ if (oldest !== void 0) {
36
+ map.delete(oldest);
37
+ }
38
+ };
39
+
40
+ const textEncoder = new TextEncoder();
41
+ const fromBase64Url = (input) => {
42
+ const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
43
+ const binary = atob(padded);
44
+ const bytes = new Uint8Array(binary.length);
45
+ for (let index = 0; index < binary.length; index += 1) {
46
+ bytes[index] = binary.codePointAt(index) ?? 0;
47
+ }
48
+ return bytes;
49
+ };
50
+ const KEY_CACHE_MAX = 64;
51
+ const keyCache = /* @__PURE__ */ new Map();
52
+ const importHmacKey = async (secret) => {
53
+ const cached = keyCache.get(secret);
54
+ if (cached) {
55
+ return cached;
56
+ }
57
+ evictOldestEntry(keyCache, KEY_CACHE_MAX);
58
+ const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
59
+ keyCache.set(secret, keyPromise);
60
+ return keyPromise;
61
+ };
62
+ const verifyCanonical = async (secret, canonical, sigBytes) => {
63
+ const cryptoKey = await importHmacKey(secret);
64
+ return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
65
+ };
66
+
67
+ const WS_ADMIN_TOKEN_VERSION = "v1";
68
+ const verifyWsAdminToken = async (secret, token, now = Date.now()) => {
69
+ if (secret.length === 0 || token.length === 0) {
70
+ return false;
71
+ }
72
+ const parts = token.split(".");
73
+ if (parts.length !== 3) {
74
+ return false;
75
+ }
76
+ const [version, expString, signature] = parts;
77
+ if (version !== WS_ADMIN_TOKEN_VERSION || signature.length === 0) {
78
+ return false;
79
+ }
80
+ const expiresAtMs = Number(expString);
81
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
82
+ return false;
83
+ }
84
+ let signatureBytes;
85
+ try {
86
+ signatureBytes = fromBase64Url(signature);
87
+ } catch {
88
+ return false;
89
+ }
90
+ return verifyCanonical(secret, `${version}.${expString}`, signatureBytes);
91
+ };
92
+
28
93
  const AUDIT_LOG_TABLE = "__lunora_audit__";
29
94
  const AUDIT_LOG_RETENTION = 1e3;
30
95
  const runSql$3 = (sql, query, ...params) => {
@@ -321,7 +386,7 @@ const parseRelayName = (name) => {
321
386
  return { ownerKey, relayIndex };
322
387
  };
323
388
 
324
- const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
389
+ const shapeRoutingKey = (name, args) => stableWireKey({ args: args ?? {}, name });
325
390
  const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
326
391
  const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
327
392
  if (thresholds.tDown >= thresholds.tUp) {
@@ -480,12 +545,12 @@ class RelayLink {
480
545
  case "relay_shape_poke": {
481
546
  const iterated = this.host.getWebSockets().length;
482
547
  const startMs = Date.now();
483
- const delivered = this.onShapePoke(message);
548
+ const delivered = this.onShapePoke({ ...message, args: decodeWire(message.args) });
484
549
  this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
485
550
  return noContent();
486
551
  }
487
552
  case "relay_shape_subscribe": {
488
- return jsonRelayResponse(this.onShapeSubscribe(message));
553
+ return jsonRelayResponse(this.onShapeSubscribe({ ...message, args: decodeWire(message.args) }));
489
554
  }
490
555
  default: {
491
556
  return assertNeverFrame(message);
@@ -677,7 +742,9 @@ class OwnerRelay extends RelayLink {
677
742
  }
678
743
  entry.cursor = frameCursor;
679
744
  const poke = {
680
- args: entry.args,
745
+ // `args` wire-encoded for the same reason as `rowsPatch` below; the
746
+ // relay decodes them at `handleControl` before the routing match.
747
+ args: encodeWire(entry.args),
681
748
  checkpoint: frameCursor,
682
749
  epoch,
683
750
  fromCursor,
@@ -724,7 +791,9 @@ class OwnerRelay extends RelayLink {
724
791
  }
725
792
  entry.cursor = frameCursor;
726
793
  const poke = {
727
- args: entry.args,
794
+ // `args` wire-encoded like `rowsPatch`; decoded relay-side at
795
+ // `handleControl` before the routing match.
796
+ args: encodeWire(entry.args),
728
797
  checkpoint: frameCursor,
729
798
  epoch,
730
799
  fromCursor,
@@ -859,8 +928,8 @@ class OwnerRelay extends RelayLink {
859
928
  if (this.tableHasAnyMask(base.table)) {
860
929
  return false;
861
930
  }
862
- const baseWhere = stableStringify(base.effectiveWhere);
863
- const baseColumns = stableStringify(base.columns);
931
+ const baseWhere = stableWireKey(base.effectiveWhere);
932
+ const baseColumns = stableWireKey(base.columns);
864
933
  let enumerated = false;
865
934
  const populate = (side) => {
866
935
  const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
@@ -890,7 +959,7 @@ class OwnerRelay extends RelayLink {
890
959
  } catch {
891
960
  return false;
892
961
  }
893
- return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableStringify(resolved.effectiveWhere) === baseWhere && stableStringify(resolved.columns) === baseColumns;
962
+ return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableWireKey(resolved.effectiveWhere) === baseWhere && stableWireKey(resolved.columns) === baseColumns;
894
963
  });
895
964
  return matches && !enumerated;
896
965
  }
@@ -938,7 +1007,10 @@ class RelayMember extends RelayLink {
938
1007
  }
939
1008
  await this.announce();
940
1009
  const request = {
941
- args: shape.args ?? {},
1010
+ // Wire-encode before the relay->owner `JSON.stringify` hop: the shard
1011
+ // decoded these args at its `shape_subscribe` entry point, so a
1012
+ // `bigint`/`Date`/bytes arg would otherwise throw (or corrupt) here.
1013
+ args: encodeWire(shape.args ?? {}),
942
1014
  connectionId: this.host.readAttachment(ws).connectionId,
943
1015
  identity: identity.identity,
944
1016
  name: shape.name,
@@ -1420,6 +1492,7 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
1420
1492
 
1421
1493
  const WS_KEEPALIVE_PING = "lunora-ping";
1422
1494
  const WS_KEEPALIVE_PONG = "lunora-pong";
1495
+ const REQUIRE_EPHEMERAL_ENV_VALUES = /* @__PURE__ */ new Set(["1", "enabled", "on", "true", "yes"]);
1423
1496
  const UNDELIVERED_BASELINE = "<undelivered>";
1424
1497
  const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
1425
1498
  const CDC_RESUME_SCAN_LIMIT = 1e4;
@@ -2512,7 +2585,24 @@ class ShardDO {
2512
2585
  ws.send(JSON.stringify({ id: envelope.id, message: "admin subscription requires admin authorization", type: "error" }));
2513
2586
  return;
2514
2587
  }
2515
- const status = this.subscribe(ws, envelope.id, envelope.query);
2588
+ let query;
2589
+ try {
2590
+ query = envelope.query.args === void 0 ? envelope.query : { ...envelope.query, args: decodeWire(envelope.query.args) };
2591
+ } catch {
2592
+ try {
2593
+ ws.send(
2594
+ JSON.stringify({
2595
+ code: "BAD_SUBSCRIPTION_ARGS",
2596
+ error: { code: "BAD_SUBSCRIPTION_ARGS", message: "subscription args failed wire decoding" },
2597
+ id: envelope.id,
2598
+ type: "error"
2599
+ })
2600
+ );
2601
+ } catch {
2602
+ }
2603
+ return;
2604
+ }
2605
+ const status = this.subscribe(ws, envelope.id, query);
2516
2606
  if (status !== "ok") {
2517
2607
  const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
2518
2608
  const errorMessage = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist subscription attachment";
@@ -2524,13 +2614,20 @@ class ShardDO {
2524
2614
  }
2525
2615
  ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
2526
2616
  if (functionPath) {
2527
- await this.seedSubscription(ws, envelope.id, envelope.query, functionPath, isAdmin);
2617
+ await this.seedSubscription(ws, envelope.id, query, functionPath, isAdmin);
2528
2618
  }
2529
2619
  return;
2530
2620
  }
2531
2621
  if (envelope.type === "shape_subscribe" && envelope.shape) {
2622
+ let shapeArgs;
2623
+ try {
2624
+ shapeArgs = envelope.shape.args === void 0 ? void 0 : decodeWire(envelope.shape.args);
2625
+ } catch {
2626
+ this.sendShapeSubscribeError(ws, envelope.id, "BAD_SUBSCRIPTION_ARGS", "shape args failed wire decoding");
2627
+ return;
2628
+ }
2532
2629
  await this.handleShapeSubscribe(ws, envelope.id, {
2533
- args: envelope.shape.args,
2630
+ args: shapeArgs,
2534
2631
  name: envelope.shape.name,
2535
2632
  sinceEpoch: envelope.sinceEpoch,
2536
2633
  sinceSeq: envelope.sinceCheckpoint
@@ -6303,9 +6400,14 @@ class ShardDO {
6303
6400
  * server logs, browser history, and `Referer` headers on any
6304
6401
  * subresource the upgrade page loads after the handshake. Use a
6305
6402
  * short-lived rotating token in production rather than a long-lived
6306
- * secret.
6403
+ * secret — for the ADMIN credential specifically, the worker mints one
6404
+ * (`POST /_lunora/admin/ws-token`) and {@link isAdminSocket} accepts it, so
6405
+ * the master `LUNORA_ADMIN_TOKEN` never rides the URL.
6406
+ *
6407
+ * Async because the admin fallback ({@link isAdminSocket}) verifies the
6408
+ * ephemeral sub-token with WebCrypto HMAC.
6307
6409
  */
6308
- isUpgradeAllowed(request) {
6410
+ async isUpgradeAllowed(request) {
6309
6411
  const env = this.env ?? {};
6310
6412
  const allowedOrigins = env.LUNORA_ALLOWED_ORIGINS;
6311
6413
  if (allowedOrigins && allowedOrigins.trim() !== "") {
@@ -6321,7 +6423,7 @@ class ShardDO {
6321
6423
  const expectedBearer = env.LUNORA_WS_BEARER;
6322
6424
  if (expectedBearer && expectedBearer.length > 0) {
6323
6425
  const supplied = this.suppliedWsToken(request);
6324
- if (!supplied || !constantTimeEqual(supplied, expectedBearer) && !this.isAdminSocket(request)) {
6426
+ if (!supplied || !constantTimeEqual(supplied, expectedBearer) && !await this.isAdminSocket(request)) {
6325
6427
  return false;
6326
6428
  }
6327
6429
  }
@@ -6342,19 +6444,40 @@ class ShardDO {
6342
6444
  return new URL(request.url).searchParams.get("token") ?? void 0;
6343
6445
  }
6344
6446
  /**
6345
- * Whether the upgrade presented a token matching `LUNORA_ADMIN_TOKEN`,
6346
- * constant-time compared. Closed (returns `false`) when the admin token is
6347
- * unset, mirroring `isAdminAuthorized` for the HTTP path so admin
6348
- * streaming is opt-in rather than exposed by default.
6447
+ * Whether the upgrade presented an admin credential: the master
6448
+ * `LUNORA_ADMIN_TOKEN` (constant-time compared) or a short-lived sub-token
6449
+ * the worker minted with it (`POST /_lunora/admin/ws-token`
6450
+ * HMAC-verified statelessly here, since both isolates hold the master token
6451
+ * in `env`). The ephemeral token is what the studio sends in `?token=`, so
6452
+ * the master credential stays out of URLs/logs. Closed (resolves `false`)
6453
+ * when the admin token is unset, mirroring `isAdminAuthorized` for the HTTP
6454
+ * path so admin streaming is opt-in rather than exposed by default.
6455
+ *
6456
+ * Enforcement: with `LUNORA_REQUIRE_EPHEMERAL_WS_TOKEN` set
6457
+ * (`1`/`true`/`on`/`yes`/`enabled`), a raw master token in the
6458
+ * `?token=` query parameter is rejected — the query string is exactly
6459
+ * where it leaks. The `Authorization` header path still takes the master
6460
+ * token: browsers can't set it on a WS upgrade, so it never rides a URL.
6349
6461
  */
6350
- isAdminSocket(request) {
6462
+ async isAdminSocket(request) {
6351
6463
  const env = this.env ?? {};
6352
6464
  const adminToken = env.LUNORA_ADMIN_TOKEN;
6353
6465
  if (!adminToken || adminToken.length === 0) {
6354
6466
  return false;
6355
6467
  }
6356
6468
  const supplied = this.suppliedWsToken(request);
6357
- return supplied !== void 0 && constantTimeEqual(supplied, adminToken);
6469
+ if (supplied === void 0) {
6470
+ return false;
6471
+ }
6472
+ if (await verifyWsAdminToken(adminToken, supplied)) {
6473
+ return true;
6474
+ }
6475
+ const fromQuery = extractBearerToken(request.headers.get("authorization")) === void 0;
6476
+ const requireEphemeral = REQUIRE_EPHEMERAL_ENV_VALUES.has((env.LUNORA_REQUIRE_EPHEMERAL_WS_TOKEN ?? "").trim().toLowerCase());
6477
+ if (fromQuery && requireEphemeral) {
6478
+ return false;
6479
+ }
6480
+ return constantTimeEqual(supplied, adminToken);
6358
6481
  }
6359
6482
  /**
6360
6483
  * Register the hibernation-safe ping/pong keepalive. The runtime answers a
@@ -6396,10 +6519,11 @@ class ShardDO {
6396
6519
  }
6397
6520
  return void 0;
6398
6521
  }
6399
- handleWebSocketUpgrade(request) {
6400
- if (!this.isUpgradeAllowed(request)) {
6522
+ async handleWebSocketUpgrade(request) {
6523
+ if (!await this.isUpgradeAllowed(request)) {
6401
6524
  return new Response("Forbidden", { status: 403 });
6402
6525
  }
6526
+ const admin = await this.isAdminSocket(request);
6403
6527
  const pair = new WebSocketPair();
6404
6528
  const client = pair[0];
6405
6529
  const server = pair[1];
@@ -6409,7 +6533,7 @@ class ShardDO {
6409
6533
  const expiresAtRaw = Number(request.headers.get("x-lunora-identity-exp"));
6410
6534
  const expiresAt = Number.isFinite(expiresAtRaw) && expiresAtRaw > 0 ? expiresAtRaw : void 0;
6411
6535
  server.serializeAttachment?.({
6412
- admin: this.isAdminSocket(request),
6536
+ admin,
6413
6537
  connectionId: crypto.randomUUID(),
6414
6538
  subs: {},
6415
6539
  ...expiresAt === void 0 ? {} : { expiresAt },
@@ -1,5 +1,6 @@
1
- import { stableStringify } from './stableStringify-MydiuScU.mjs';
1
+ import { stableWireKey } from './stableWireKey-DKuXO7T5.mjs';
2
2
  import { depKey, SCAN_DEP } from './SCAN_DEP-DLJF8dsj.mjs';
3
+ export { stableStringify } from './stableStringify-mC40mZts.mjs';
3
4
 
4
5
  const DEFAULT_MAX_ENTRIES = 1e3;
5
6
  const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
@@ -227,6 +228,6 @@ class ReactiveCache {
227
228
  }
228
229
  }
229
230
  }
230
- const reactiveCacheKey = (functionPath, args, identity) => `${identity ?? "\0anon"}\0${functionPath}:${stableStringify(args)}`;
231
+ const reactiveCacheKey = (functionPath, args, identity) => `${identity ?? "\0anon"}\0${functionPath}:${stableWireKey(args)}`;
231
232
 
232
- export { ReactiveCache, reactiveCacheKey, stableStringify };
233
+ export { ReactiveCache, reactiveCacheKey, stableWireKey };
@@ -1,4 +1,4 @@
1
- import { stableStringify } from './stableStringify-MydiuScU.mjs';
1
+ import { stableStringify } from './stableStringify-mC40mZts.mjs';
2
2
 
3
3
  const projectExternalSourceRow = (row, columns) => {
4
4
  const projected = { _id: row._id };
@@ -0,0 +1,183 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { sql } from 'drizzle-orm';
3
+ import { r as runDrizzle } from './do-exec-5eQy5cEi.mjs';
4
+ import { runExternalSourceTick, materializeExternalRowsIncremental } from './materializeExternalRows-FyA5Rwn4.mjs';
5
+
6
+ const SOURCE_CURSOR_TABLE = "__lunora_source_cursor";
7
+ const serializeCursor = (value) => {
8
+ if (value instanceof Date) {
9
+ return `d:${value.toISOString()}`;
10
+ }
11
+ if (typeof value === "bigint") {
12
+ return `b:${value.toString()}`;
13
+ }
14
+ if (typeof value === "number") {
15
+ return `n:${value.toString()}`;
16
+ }
17
+ return `s:${value}`;
18
+ };
19
+ const deserializeCursor = (text) => {
20
+ const rest = text.slice(2);
21
+ switch (text[0]) {
22
+ case "b": {
23
+ return BigInt(rest);
24
+ }
25
+ case "d": {
26
+ return new Date(rest);
27
+ }
28
+ case "n": {
29
+ return Number(rest);
30
+ }
31
+ default: {
32
+ return rest;
33
+ }
34
+ }
35
+ };
36
+ const INTEGER_STRING = /^-?\d+$/;
37
+ const DECIMAL_STRING = /^-?\d+(?:\.\d+)?$/;
38
+ const cursorAfter = (a, b) => {
39
+ if (a instanceof Date && b instanceof Date) {
40
+ return a.getTime() > b.getTime();
41
+ }
42
+ if (typeof a === "bigint" && typeof b === "bigint") {
43
+ return a > b;
44
+ }
45
+ if (typeof a === "number" && typeof b === "number") {
46
+ return a > b;
47
+ }
48
+ if (typeof a === "string" && typeof b === "string" && DECIMAL_STRING.test(a) && DECIMAL_STRING.test(b)) {
49
+ if (INTEGER_STRING.test(a) && INTEGER_STRING.test(b)) {
50
+ return BigInt(a) > BigInt(b);
51
+ }
52
+ return Number(a) > Number(b);
53
+ }
54
+ return String(a) > String(b);
55
+ };
56
+ const maxCursorValue = (rows, column, current) => {
57
+ let best = current === null ? void 0 : deserializeCursor(current);
58
+ for (const row of rows) {
59
+ const raw = row[column];
60
+ if (raw === null || raw === void 0) {
61
+ continue;
62
+ }
63
+ const value = raw;
64
+ if (best === void 0 || cursorAfter(value, best)) {
65
+ best = value;
66
+ }
67
+ }
68
+ return best === void 0 ? null : serializeCursor(best);
69
+ };
70
+ const migrateSourceCursor = (sql$1) => {
71
+ runDrizzle(
72
+ sql$1,
73
+ sql`CREATE TABLE IF NOT EXISTS ${sql.identifier(SOURCE_CURSOR_TABLE)} (
74
+ table_name TEXT NOT NULL,
75
+ shard_key TEXT NOT NULL,
76
+ watermark TEXT,
77
+ last_reconcile_ms INTEGER,
78
+ PRIMARY KEY (table_name, shard_key)
79
+ )`
80
+ );
81
+ };
82
+ const readSourceCursor = (sql$1, table, shardKey) => {
83
+ const rows = runDrizzle(
84
+ sql$1,
85
+ sql`SELECT watermark, last_reconcile_ms FROM ${sql.identifier(SOURCE_CURSOR_TABLE)} WHERE table_name = ${table} AND shard_key = ${shardKey} LIMIT 1`
86
+ ).toArray();
87
+ const row = rows[0];
88
+ return { lastReconcileMs: row?.last_reconcile_ms ?? null, watermark: row?.watermark ?? null };
89
+ };
90
+ const writeSourceCursor = (sql$1, table, shardKey, state) => {
91
+ runDrizzle(
92
+ sql$1,
93
+ sql`INSERT INTO ${sql.identifier(SOURCE_CURSOR_TABLE)} (table_name, shard_key, watermark, last_reconcile_ms)
94
+ VALUES (${table}, ${shardKey}, ${state.watermark}, ${state.lastReconcileMs})
95
+ ON CONFLICT(table_name, shard_key) DO UPDATE SET watermark = excluded.watermark, last_reconcile_ms = excluded.last_reconcile_ms`
96
+ );
97
+ };
98
+
99
+ const liftSourceId = (row, options = {}) => {
100
+ const { idColumn = "id", map } = options;
101
+ const idValue = row[idColumn];
102
+ if (idValue === void 0 || idValue === null) {
103
+ throw new LunoraError("INTERNAL", `external-source: row is missing id column "${idColumn}"`);
104
+ }
105
+ if (typeof idValue !== "string" && typeof idValue !== "number" && typeof idValue !== "bigint") {
106
+ throw new TypeError(`external-source: id column "${idColumn}" must be a string or number`);
107
+ }
108
+ const id = String(idValue);
109
+ if (map) {
110
+ return { ...map(row), _id: id };
111
+ }
112
+ const body = {};
113
+ for (const [key, value] of Object.entries(row)) {
114
+ if (key !== idColumn) {
115
+ body[key] = value;
116
+ }
117
+ }
118
+ return { ...body, _id: id };
119
+ };
120
+ const isSourceDue = (refresh, lastPolledMs, nowMs) => {
121
+ if (refresh === "manual") {
122
+ return false;
123
+ }
124
+ if (refresh === void 0 || lastPolledMs === void 0) {
125
+ return true;
126
+ }
127
+ return nowMs - lastPolledMs >= refresh.everyMs;
128
+ };
129
+ const pullAndLift = async (client, query, parameters, source) => {
130
+ const rows = await client.query(query, parameters);
131
+ const documents = rows.map((row) => liftSourceId(row, { idColumn: source.idColumn, map: source.map }));
132
+ return { documents, rows };
133
+ };
134
+ const pullExternalSourceTick = async (sql, writer, client, table, source, shardKey) => {
135
+ const parameters = source.tenantBy ? source.tenantBy(shardKey) : [];
136
+ const { documents } = await pullAndLift(client, source.query, parameters, source);
137
+ return runExternalSourceTick(sql, writer, documents, { columns: source.columns, table });
138
+ };
139
+ const isSoftDeleted = (row, column) => {
140
+ const value = row[column];
141
+ return value !== null && value !== void 0 && value !== false && value !== 0;
142
+ };
143
+ const pullExternalSourceIncrementalTick = async (sql, writer, client, table, source, shardKey, nowMs) => {
144
+ const { cursor } = source;
145
+ if (!cursor) {
146
+ throw new LunoraError(
147
+ "INTERNAL",
148
+ `external-source: table "${table}" is mode "incremental" but has no \`cursor\` — this should have been rejected at defineSchema`
149
+ );
150
+ }
151
+ migrateSourceCursor(sql);
152
+ const state = readSourceCursor(sql, table, shardKey);
153
+ const tenantParameters = source.tenantBy ? source.tenantBy(shardKey) : [];
154
+ const reconcileDue = source.reconcileEveryMs !== void 0 && (state.lastReconcileMs === null || nowMs - state.lastReconcileMs >= source.reconcileEveryMs);
155
+ const fullPull = state.watermark === null || reconcileDue;
156
+ let slice;
157
+ let applied;
158
+ if (state.watermark === null || reconcileDue) {
159
+ slice = await pullAndLift(client, source.query, tenantParameters, source);
160
+ ({ applied } = await runExternalSourceTick(sql, writer, slice.documents, { columns: source.columns, table }));
161
+ } else {
162
+ slice = await pullAndLift(client, cursor.query, [...tenantParameters, deserializeCursor(state.watermark)], source);
163
+ const { softDeleteColumn } = source;
164
+ const deletedIds = softDeleteColumn ? new Set(
165
+ slice.documents.flatMap((document, index) => {
166
+ const row = slice.rows[index];
167
+ return row && isSoftDeleted(row, softDeleteColumn) ? [String(document._id)] : [];
168
+ })
169
+ ) : void 0;
170
+ ({ applied } = await materializeExternalRowsIncremental(writer, slice.documents, { columns: source.columns, deletedIds, table }));
171
+ }
172
+ const watermark = maxCursorValue(slice.rows, cursor.column, state.watermark);
173
+ if (fullPull && watermark === null && slice.rows.length > 0) {
174
+ throw new LunoraError(
175
+ "INTERNAL",
176
+ `external-source: table "${table}" (mode "incremental") pulled ${String(slice.rows.length)} rows but none carry the cursor column "${cursor.column}" — the seed \`query\` must project it (matching \`cursor.query\`'s alias), or the watermark can never advance.`
177
+ );
178
+ }
179
+ writeSourceCursor(sql, table, shardKey, { lastReconcileMs: fullPull ? nowMs : state.lastReconcileMs, watermark });
180
+ return { applied };
181
+ };
182
+
183
+ export { isSoftDeleted, isSourceDue, liftSourceId, pullExternalSourceIncrementalTick, pullExternalSourceTick };
@@ -1,13 +1,32 @@
1
1
  import { applyCdcChanges } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
2
2
  import { s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
3
- import { diffExternalSource, projectExternalSourceRow } from './diffExternalSource-Cx9HUPJj.mjs';
4
- import { stableStringify } from './stableStringify-MydiuScU.mjs';
3
+ import { diffExternalSource, projectExternalSourceRow } from './diffExternalSource-CovHfdyo.mjs';
4
+ import { stableStringify } from './stableStringify-mC40mZts.mjs';
5
5
 
6
6
  const materializeExternalRows = async (writer, pulled, baseline, options) => {
7
7
  const { changes, nextBaseline } = diffExternalSource(pulled, baseline, options);
8
8
  await applyCdcChanges(writer, changes);
9
9
  return { applied: changes.length, nextBaseline };
10
10
  };
11
+ const materializeExternalRowsIncremental = async (writer, pulled, options) => {
12
+ const { columns, deletedIds, table } = options;
13
+ const changes = [];
14
+ for (const source of pulled) {
15
+ const value = projectExternalSourceRow(source, columns);
16
+ const id = String(value._id);
17
+ if (deletedIds?.has(id)) {
18
+ changes.push({ id, op: "delete", seq: 0, table, ts: 0 });
19
+ continue;
20
+ }
21
+ const stored = await writer.get(id, table);
22
+ if (stored && stableStringify(projectExternalSourceRow({ ...stored, _id: id }, columns)) === stableStringify(value)) {
23
+ continue;
24
+ }
25
+ changes.push({ doc: value, id, op: "insert", seq: 0, table, ts: 0 });
26
+ }
27
+ await applyCdcChanges(writer, changes);
28
+ return { applied: changes.length };
29
+ };
11
30
  const readExternalSourceBaseline = (sql, table, columns) => {
12
31
  const baseline = /* @__PURE__ */ new Map();
13
32
  for (const { doc, id } of selectShapeRows(sql, table, void 0)) {
@@ -20,4 +39,4 @@ const runExternalSourceTick = async (sql, writer, pulled, options) => {
20
39
  return materializeExternalRows(writer, pulled, baseline, options);
21
40
  };
22
41
 
23
- export { materializeExternalRows, readExternalSourceBaseline, runExternalSourceTick };
42
+ export { materializeExternalRows, materializeExternalRowsIncremental, readExternalSourceBaseline, runExternalSourceTick };
@@ -9,7 +9,7 @@ const stableStringify = (value) => {
9
9
  return "null";
10
10
  }
11
11
  if (typeof value === "bigint") {
12
- throw new TypeError("stableStringify: cannot use a bigint in a cache key (query/subscription/shape args) — pass it as a string");
12
+ throw new TypeError("stableStringify: cannot use a bigint in a stable JSON cache key — pass it as a string, or use stableWireKey");
13
13
  }
14
14
  if (value === null || typeof value !== "object") {
15
15
  return JSON.stringify(value);
@@ -21,7 +21,7 @@ const stableStringify = (value) => {
21
21
  if (proto !== null && proto !== Object.prototype) {
22
22
  const name = value.constructor?.name ?? "value";
23
23
  throw new TypeError(
24
- `stableStringify: cannot use a ${name} in a cache key (query/subscription/shape args) — only plain objects, arrays, and JSON primitives are supported`
24
+ `stableStringify: cannot use a ${name} in a stable JSON cache key — only plain objects, arrays, and JSON primitives are supported (wire-typed values key via stableWireKey)`
25
25
  );
26
26
  }
27
27
  const record = value;
@@ -0,0 +1,6 @@
1
+ import { stableStringify } from './stableStringify-mC40mZts.mjs';
2
+ import { e as encodeWire } from './wire-codec-CzQc1pvf.mjs';
3
+
4
+ const stableWireKey = (value) => stableStringify(encodeWire(value));
5
+
6
+ export { stableWireKey };