@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.
- package/dist/index.d.mts +105 -6
- package/dist/index.d.ts +105 -6
- package/dist/index.mjs +9 -8
- package/dist/packem_shared/{MAX_SQL_ROWS-D57CJaT9.mjs → MAX_SQL_ROWS-iFAA8FbD.mjs} +32 -2
- package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-BA8QOChj.mjs → ROOT_DO_SIZE_WARN_BYTES-BWz51mpB.mjs} +152 -28
- package/dist/packem_shared/{ReactiveCache-BYlSGY0N.mjs → ReactiveCache-DnSvbjil.mjs} +4 -3
- package/dist/packem_shared/{diffExternalSource-Cx9HUPJj.mjs → diffExternalSource-CovHfdyo.mjs} +1 -1
- package/dist/packem_shared/isSoftDeleted-i5kKh6Up.mjs +183 -0
- package/dist/packem_shared/{materializeExternalRows-CTqZisSC.mjs → materializeExternalRows-FyA5Rwn4.mjs} +22 -3
- package/dist/packem_shared/{stableStringify-MydiuScU.mjs → stableStringify-mC40mZts.mjs} +2 -2
- package/dist/packem_shared/stableWireKey-DKuXO7T5.mjs +6 -0
- package/dist/packem_shared/subscriptionListDeltas-CT76bYny.mjs +126 -0
- package/dist/packem_shared/{subscription-delivery-CWigSEr3.mjs → wire-codec-CzQc1pvf.mjs} +1 -124
- package/package.json +3 -3
- package/dist/packem_shared/isSourceDue-CYkt7Ru8.mjs +0 -41
- package/dist/packem_shared/subscriptionListDeltas-DRLvFlM1.mjs +0 -1
|
@@ -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,
|
|
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-
|
|
16
|
-
import {
|
|
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-
|
|
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) =>
|
|
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
|
|
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
|
|
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 =
|
|
863
|
-
const baseColumns =
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
6346
|
-
* constant-time compared
|
|
6347
|
-
*
|
|
6348
|
-
*
|
|
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
|
-
|
|
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
|
|
6536
|
+
admin,
|
|
6413
6537
|
connectionId: crypto.randomUUID(),
|
|
6414
6538
|
subs: {},
|
|
6415
6539
|
...expiresAt === void 0 ? {} : { expiresAt },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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}:${
|
|
231
|
+
const reactiveCacheKey = (functionPath, args, identity) => `${identity ?? "\0anon"}\0${functionPath}:${stableWireKey(args)}`;
|
|
231
232
|
|
|
232
|
-
export { ReactiveCache, reactiveCacheKey,
|
|
233
|
+
export { ReactiveCache, reactiveCacheKey, stableWireKey };
|
|
@@ -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-
|
|
4
|
-
import { stableStringify } from './stableStringify-
|
|
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
|
|
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
|
|
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;
|