@lunora/do 0.0.0 → 1.0.0-alpha.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.
Files changed (47) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +115 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/index.d.mts +5599 -0
  5. package/dist/index.d.ts +5599 -0
  6. package/dist/index.mjs +35 -0
  7. package/dist/packem_shared/ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs +313 -0
  8. package/dist/packem_shared/AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs +84 -0
  9. package/dist/packem_shared/ConflictError-C0STs6bU.mjs +13 -0
  10. package/dist/packem_shared/CountRlsUnsupportedError-28ZvvwKS.mjs +133 -0
  11. package/dist/packem_shared/DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs +237 -0
  12. package/dist/packem_shared/LogBuffer-B_Ezju_N.mjs +37 -0
  13. package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +10 -0
  14. package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs +4009 -0
  15. package/dist/packem_shared/ReactiveCache-ByVzgH3d.mjs +259 -0
  16. package/dist/packem_shared/SCAN_DEP-DLJF8dsj.mjs +19 -0
  17. package/dist/packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs +180 -0
  18. package/dist/packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs +146 -0
  19. package/dist/packem_shared/aggregateTableName-CxNqY1Sl.mjs +64 -0
  20. package/dist/packem_shared/applyCdcChanges-Ctdmxmrv.mjs +103 -0
  21. package/dist/packem_shared/applyOnDelete-CMif2RKw.mjs +165 -0
  22. package/dist/packem_shared/armRestore-BJk53Ro8.mjs +55 -0
  23. package/dist/packem_shared/assertFlatPredicate-DyVYReuT.mjs +160 -0
  24. package/dist/packem_shared/assertReadonly-dDcFE1YZ.mjs +29 -0
  25. package/dist/packem_shared/assertValidClientId-CBZ1zC96.mjs +1745 -0
  26. package/dist/packem_shared/backfillAggregateIndexes-BF5eL7kW.mjs +80 -0
  27. package/dist/packem_shared/buildSecurityAudit-CCAvoFlr.mjs +1 -0
  28. package/dist/packem_shared/buildSeekWhere-lVsNXSLy.mjs +84 -0
  29. package/dist/packem_shared/clearCapturedMail-CPpgl-dX.mjs +104 -0
  30. package/dist/packem_shared/compileWhereSql-CXrhFA3G.mjs +127 -0
  31. package/dist/packem_shared/createSystemReader-8CzSZP9V.mjs +80 -0
  32. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +35 -0
  33. package/dist/packem_shared/do-exec-5eQy5cEi.mjs +12 -0
  34. package/dist/packem_shared/do-sql-BCHCWtrD.mjs +87 -0
  35. package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +1 -0
  36. package/dist/packem_shared/ensureFunctionMetricsTables-UDNVD7FS.mjs +248 -0
  37. package/dist/packem_shared/exportShardRows-DZEhUeyI.mjs +156 -0
  38. package/dist/packem_shared/ftsTableName-BLEMawrp.mjs +38 -0
  39. package/dist/packem_shared/guardWriter-u3UlnCH5.mjs +128 -0
  40. package/dist/packem_shared/matchesStaticWhere-CFk6adSu.mjs +54 -0
  41. package/dist/packem_shared/rank-CrkEIpF4.mjs +102 -0
  42. package/dist/packem_shared/renderSql-D6eUcn2N.mjs +16 -0
  43. package/dist/packem_shared/runShardMigrations-C3bn5r93.mjs +103 -0
  44. package/dist/packem_shared/runTriggers-5N6_Fx0A.mjs +20 -0
  45. package/dist/packem_shared/security-audit-CucgBice.mjs +158 -0
  46. package/dist/packem_shared/serveRelationFanout-Clr1a05L.mjs +24 -0
  47. package/package.json +41 -17
@@ -0,0 +1,259 @@
1
+ import { depKey, SCAN_DEP } from './SCAN_DEP-DLJF8dsj.mjs';
2
+
3
+ const compareKeys = (a, b) => {
4
+ if (a < b) {
5
+ return -1;
6
+ }
7
+ return a > b ? 1 : 0;
8
+ };
9
+ const DEFAULT_MAX_ENTRIES = 1e3;
10
+ const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
11
+ const estimateBytes = (value) => {
12
+ if (value === void 0 || value === null) {
13
+ return 0;
14
+ }
15
+ try {
16
+ return JSON.stringify(value).length;
17
+ } catch {
18
+ return DEFAULT_MAX_BYTES;
19
+ }
20
+ };
21
+ class ReactiveCache {
22
+ /** key -> entry. Map insertion order doubles as LRU order. */
23
+ entries = /* @__PURE__ */ new Map();
24
+ /** `table:id` (or `table:*scan`) -> set of cache keys that depend on it. */
25
+ tableIndex = /* @__PURE__ */ new Map();
26
+ /** Cumulative byte charge across `entries`. Tracked incrementally. */
27
+ totalBytes = 0;
28
+ /** Lifetime cache-hit count, surfaced via `stats()`. */
29
+ hits = 0;
30
+ /** Lifetime cache-miss count (callback ran), surfaced via `stats()`. */
31
+ misses = 0;
32
+ /** Lifetime count of entries dropped by the LRU evictor. */
33
+ evictions = 0;
34
+ maxEntries;
35
+ maxBytes;
36
+ now;
37
+ /**
38
+ * Monotonic counter — used as the default clock so `lastUsed` strictly
39
+ * orders calls even when they land in the same wall-clock millisecond.
40
+ */
41
+ monotonic = 0;
42
+ constructor(options = {}) {
43
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
44
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
45
+ this.now = options.now ?? (() => {
46
+ this.monotonic += 1;
47
+ return this.monotonic;
48
+ });
49
+ }
50
+ /**
51
+ * Return the cached result for `key` if present (and re-stamp it as
52
+ * most-recently-used); otherwise run the callback, store the result with
53
+ * `deps` as its invalidation footprint, and return it. The caller is
54
+ * responsible for collecting `deps` via a `DependencyTracker` during
55
+ * the callback and handing the same set in here — the cache stores the
56
+ * reference verbatim, so the caller MUST stop mutating it after this
57
+ * call returns.
58
+ *
59
+ * The callback is awaited inside the cache so concurrent callers for the
60
+ * same key still race the underlying handler — a real Convex-style
61
+ * "in-flight dedup" would add a `Map&lt;key, Promise>`; we keep this simpler
62
+ * and accept that the first uncached hit may run the handler twice when
63
+ * two callers arrive on the same tick. The single-DO concurrency model
64
+ * makes that race vanishingly rare in practice.
65
+ */
66
+ async run(key, deps, run) {
67
+ const existing = this.entries.get(key);
68
+ if (existing) {
69
+ this.hits += 1;
70
+ existing.lastUsed = this.now();
71
+ this.entries.delete(key);
72
+ this.entries.set(key, existing);
73
+ return existing.result;
74
+ }
75
+ this.misses += 1;
76
+ const result = await run();
77
+ const bytes = estimateBytes(result);
78
+ const entry = {
79
+ bytes,
80
+ deps,
81
+ lastUsed: this.now(),
82
+ result,
83
+ subscribers: /* @__PURE__ */ new Set()
84
+ };
85
+ this.entries.set(key, entry);
86
+ this.totalBytes += bytes;
87
+ for (const dep of deps) {
88
+ let bucket = this.tableIndex.get(dep);
89
+ if (!bucket) {
90
+ bucket = /* @__PURE__ */ new Set();
91
+ this.tableIndex.set(dep, bucket);
92
+ }
93
+ bucket.add(key);
94
+ }
95
+ this.evict();
96
+ return result;
97
+ }
98
+ /**
99
+ * Invalidate every entry that recorded a read of `(table, id)` OR a full
100
+ * scan of `table` (the `*scan` marker). Returns the keys removed so the
101
+ * caller can re-run their subscribers — see `ShardDO#flushChangedTables`
102
+ * for the wired-up consumer.
103
+ */
104
+ invalidate(table, id) {
105
+ const removed = [];
106
+ this.collectAndDrop(depKey(table, id), removed);
107
+ this.collectAndDrop(depKey(table, SCAN_DEP), removed);
108
+ return removed;
109
+ }
110
+ /**
111
+ * Nuke every entry that depends on `table` in any form (rows + `*scan`).
112
+ * Wired in by the writer for operations that can't pinpoint a row id
113
+ * (e.g. bulk truncate). For the common single-row write path, prefer
114
+ * {@link invalidate} so per-id entries on other rows survive.
115
+ */
116
+ invalidateTable(table) {
117
+ const removed = [];
118
+ const prefix = `${table}:`;
119
+ for (const dep of this.tableIndex.keys()) {
120
+ if (dep.startsWith(prefix)) {
121
+ this.collectAndDrop(dep, removed);
122
+ }
123
+ }
124
+ return removed;
125
+ }
126
+ /**
127
+ * Register `subscriberId` as interested in re-runs of `key`. Subscribers
128
+ * pin the entry against eviction — see {@link evict}. A subscriber on a
129
+ * key that isn't cached yet is a no-op (the first `run()` will land the
130
+ * entry, but the subscription registration is lost). Callers SHOULD
131
+ * subscribe AFTER the first `run()` returns to avoid that gap.
132
+ */
133
+ subscribe(key, subscriberId) {
134
+ const entry = this.entries.get(key);
135
+ if (!entry) {
136
+ return;
137
+ }
138
+ entry.subscribers.add(subscriberId);
139
+ }
140
+ /** Detach a subscriber from `key`. Idempotent on missing entries. */
141
+ unsubscribe(key, subscriberId) {
142
+ const entry = this.entries.get(key);
143
+ if (!entry) {
144
+ return;
145
+ }
146
+ entry.subscribers.delete(subscriberId);
147
+ }
148
+ /** Read-only view of the cache's current resource usage. */
149
+ size() {
150
+ return { bytes: this.totalBytes, entries: this.entries.size };
151
+ }
152
+ /** Drop every cached entry. Used by tests for isolation. */
153
+ clear() {
154
+ this.entries.clear();
155
+ this.tableIndex.clear();
156
+ this.totalBytes = 0;
157
+ }
158
+ /**
159
+ * Look up the entry's subscribers without exposing the internal map.
160
+ * Returned as a snapshot so the caller can iterate without worrying about
161
+ * concurrent unsubscribes mid-loop.
162
+ */
163
+ subscribers(key) {
164
+ const entry = this.entries.get(key);
165
+ if (!entry) {
166
+ return [];
167
+ }
168
+ return [...entry.subscribers];
169
+ }
170
+ /**
171
+ * Snapshot of lifetime cache counters plus the current live size. Drives the
172
+ * studio's metrics panel; cheap, allocation-light, and side-effect-free.
173
+ */
174
+ stats() {
175
+ return {
176
+ bytes: this.totalBytes,
177
+ entries: this.entries.size,
178
+ evictions: this.evictions,
179
+ hits: this.hits,
180
+ misses: this.misses
181
+ };
182
+ }
183
+ /** Pull `dep`'s bucket from the index and remove every entry in it. */
184
+ collectAndDrop(dep, removed) {
185
+ const bucket = this.tableIndex.get(dep);
186
+ if (!bucket) {
187
+ return;
188
+ }
189
+ for (const key of bucket) {
190
+ const entry = this.entries.get(key);
191
+ if (!entry) {
192
+ continue;
193
+ }
194
+ this.dropEntry(key, entry);
195
+ removed.push(key);
196
+ }
197
+ }
198
+ /** Remove an entry from every index and decrement the byte charge. */
199
+ dropEntry(key, entry) {
200
+ this.entries.delete(key);
201
+ this.totalBytes -= entry.bytes;
202
+ for (const dep of entry.deps) {
203
+ const bucket = this.tableIndex.get(dep);
204
+ if (!bucket) {
205
+ continue;
206
+ }
207
+ bucket.delete(key);
208
+ if (bucket.size === 0) {
209
+ this.tableIndex.delete(dep);
210
+ }
211
+ }
212
+ }
213
+ /**
214
+ * Run LRU eviction until both caps hold. Subscribed entries are pinned —
215
+ * if every survivor has subscribers we exit with the caps still breached
216
+ * (better than ejecting an actively-watched query and forcing a re-run
217
+ * storm on its next refresh).
218
+ */
219
+ evict() {
220
+ if (this.entries.size <= this.maxEntries && this.totalBytes <= this.maxBytes) {
221
+ return;
222
+ }
223
+ for (const [key, entry] of this.entries) {
224
+ if (this.entries.size <= this.maxEntries && this.totalBytes <= this.maxBytes) {
225
+ return;
226
+ }
227
+ if (entry.subscribers.size > 0) {
228
+ continue;
229
+ }
230
+ this.dropEntry(key, entry);
231
+ this.evictions += 1;
232
+ }
233
+ }
234
+ }
235
+ const stableStringify = (value) => {
236
+ if (value === void 0) {
237
+ return "null";
238
+ }
239
+ if (value === null || typeof value !== "object") {
240
+ return JSON.stringify(value);
241
+ }
242
+ if (Array.isArray(value)) {
243
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
244
+ }
245
+ const record = value;
246
+ const keys = Object.keys(record).toSorted(compareKeys);
247
+ const parts = [];
248
+ for (const key of keys) {
249
+ const raw = record[key];
250
+ if (raw === void 0) {
251
+ continue;
252
+ }
253
+ parts.push(`${JSON.stringify(key)}:${stableStringify(raw)}`);
254
+ }
255
+ return `{${parts.join(",")}}`;
256
+ };
257
+ const reactiveCacheKey = (functionPath, args, identity) => `${identity ?? "\0anon"}\0${functionPath}:${stableStringify(args)}`;
258
+
259
+ export { ReactiveCache, reactiveCacheKey, stableStringify };
@@ -0,0 +1,19 @@
1
+ const SCAN_DEP = "*scan";
2
+ const depKey = (table, idOrScan) => `${table}:${idOrScan}`;
3
+ const tableFromDepKey = (dep) => {
4
+ const colon = dep.indexOf(":");
5
+ return colon === -1 ? dep : dep.slice(0, colon);
6
+ };
7
+ const createDependencyTracker = () => {
8
+ const deps = /* @__PURE__ */ new Set();
9
+ return {
10
+ collect() {
11
+ return deps;
12
+ },
13
+ recordRead(table, idOrScan) {
14
+ deps.add(depKey(table, idOrScan));
15
+ }
16
+ };
17
+ };
18
+
19
+ export { SCAN_DEP, createDependencyTracker, depKey, tableFromDepKey };
@@ -0,0 +1,180 @@
1
+ const SESSION_DO_TTL_DEFAULT = 7 * 24 * 60 * 60;
2
+ const SESSION_DO_TTL_MAX = 90 * 24 * 60 * 60;
3
+ const SESSION_GC_INTERVAL_MS = 24 * 60 * 60 * 1e3;
4
+ const SESSION_SECRET_HEADER = "x-lunora-session-do-secret";
5
+ const SESSION_TOKEN_HEADER = "x-lunora-session-token";
6
+ const SESSION_TOKEN_PATTERN = /^[\w-]+$/;
7
+ const MIN_TOKEN_LENGTH = 32;
8
+ const MAX_TOKEN_LENGTH = 256;
9
+ const MAX_USER_ID_LENGTH = 256;
10
+ const jsonResponse = (status, body) => Response.json(body, {
11
+ headers: { "content-type": "application/json" },
12
+ status
13
+ });
14
+ const constantTimeEqual = (a, b) => {
15
+ const max = Math.max(a.length, b.length);
16
+ let diff = a.length ^ b.length;
17
+ for (let index = 0; index < max; index += 1) {
18
+ const charA = index < a.length ? a.charCodeAt(index) : 0;
19
+ const charB = index < b.length ? b.charCodeAt(index) : 0;
20
+ diff |= charA ^ charB;
21
+ }
22
+ return diff === 0;
23
+ };
24
+ const isAuthorized = (request, env) => {
25
+ const expected = env.SESSION_DO_SECRET;
26
+ if (typeof expected !== "string" || expected.length === 0) {
27
+ return false;
28
+ }
29
+ const supplied = request.headers.get(SESSION_SECRET_HEADER);
30
+ if (typeof supplied !== "string" || supplied.length === 0) {
31
+ return false;
32
+ }
33
+ return constantTimeEqual(expected, supplied);
34
+ };
35
+ const resolveTtlSeconds = (raw) => {
36
+ let ttlSeconds;
37
+ if (raw === void 0 || raw === null) {
38
+ ttlSeconds = SESSION_DO_TTL_DEFAULT;
39
+ } else if (typeof raw === "number") {
40
+ ttlSeconds = raw;
41
+ } else {
42
+ return void 0;
43
+ }
44
+ if (!Number.isFinite(ttlSeconds) || !Number.isInteger(ttlSeconds) || ttlSeconds <= 0 || ttlSeconds > SESSION_DO_TTL_MAX) {
45
+ return void 0;
46
+ }
47
+ return ttlSeconds;
48
+ };
49
+ const validateToken = (value) => {
50
+ if (typeof value !== "string") {
51
+ return void 0;
52
+ }
53
+ if (value.length < MIN_TOKEN_LENGTH || value.length > MAX_TOKEN_LENGTH) {
54
+ return void 0;
55
+ }
56
+ if (!SESSION_TOKEN_PATTERN.test(value)) {
57
+ return void 0;
58
+ }
59
+ return value;
60
+ };
61
+ class SessionDO {
62
+ state;
63
+ env;
64
+ constructor(state, env) {
65
+ this.state = state;
66
+ this.env = env;
67
+ }
68
+ async fetch(request) {
69
+ const env = this.env ?? {};
70
+ if (!isAuthorized(request, env)) {
71
+ return jsonResponse(401, { error: { code: "UNAUTHORIZED", message: "missing or invalid SessionDO secret" } });
72
+ }
73
+ const url = new URL(request.url);
74
+ if (request.method === "POST" && url.pathname === "/create") {
75
+ return this.handleCreate(request);
76
+ }
77
+ if (request.method === "GET" && url.pathname === "/get") {
78
+ return this.handleGet(request);
79
+ }
80
+ if (request.method === "DELETE" && url.pathname === "/revoke") {
81
+ return this.handleRevoke(request);
82
+ }
83
+ return jsonResponse(404, { error: { code: "NOT_FOUND", message: "no such session route" } });
84
+ }
85
+ /**
86
+ * Sweep expired session records. Lazy expiry-on-read ({@link handleGet})
87
+ * already keeps reads correct; this reclaims storage for sessions that are
88
+ * never read again. Re-arms itself while any sessions remain so the DO goes
89
+ * fully idle (no billable alarm) once it's empty.
90
+ */
91
+ async alarm() {
92
+ const { storage } = this.state;
93
+ if (!storage.list) {
94
+ return;
95
+ }
96
+ const now = Date.now();
97
+ const entries = await storage.list({ prefix: "s:" });
98
+ const expired = [];
99
+ let remaining = 0;
100
+ for (const [key, record] of entries) {
101
+ if (record.expiresAt < now) {
102
+ expired.push(key);
103
+ } else {
104
+ remaining += 1;
105
+ }
106
+ }
107
+ for (const key of expired) {
108
+ await storage.delete(key);
109
+ }
110
+ if (remaining > 0 && storage.setAlarm) {
111
+ await storage.setAlarm(now + SESSION_GC_INTERVAL_MS);
112
+ }
113
+ }
114
+ async handleCreate(request) {
115
+ let body;
116
+ try {
117
+ body = await request.json();
118
+ } catch {
119
+ return jsonResponse(400, { error: "invalid_request" });
120
+ }
121
+ const token = validateToken(body.token);
122
+ if (token === void 0) {
123
+ return jsonResponse(400, { error: "invalid_request" });
124
+ }
125
+ const { userId } = body;
126
+ if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LENGTH) {
127
+ return jsonResponse(400, { error: "invalid_request" });
128
+ }
129
+ const ttlSeconds = resolveTtlSeconds(body.ttlSeconds);
130
+ if (ttlSeconds === void 0) {
131
+ return jsonResponse(400, { error: "invalid_request" });
132
+ }
133
+ const now = Date.now();
134
+ const record = { createdAt: now, expiresAt: now + ttlSeconds * 1e3, userId };
135
+ await this.state.storage.put(`s:${token}`, record);
136
+ await this.armGcAlarm();
137
+ return jsonResponse(201, { token, ...record });
138
+ }
139
+ /**
140
+ * Ensure a GC alarm is pending. Only sets one when none is currently
141
+ * scheduled, so a burst of `create`s arms a single recurring sweep rather
142
+ * than thrashing the alarm. A no-op when the runtime/double doesn't expose
143
+ * the alarm API.
144
+ */
145
+ async armGcAlarm() {
146
+ const { storage } = this.state;
147
+ if (!storage.getAlarm || !storage.setAlarm) {
148
+ return;
149
+ }
150
+ const existing = await storage.getAlarm();
151
+ if (existing === null) {
152
+ await storage.setAlarm(Date.now() + SESSION_GC_INTERVAL_MS);
153
+ }
154
+ }
155
+ async handleGet(request) {
156
+ const token = request.headers.get(SESSION_TOKEN_HEADER);
157
+ if (!token) {
158
+ return jsonResponse(400, { error: { code: "INVALID_INPUT", message: "token required" } });
159
+ }
160
+ const record = await this.state.storage.get(`s:${token}`);
161
+ if (!record) {
162
+ return jsonResponse(404, { error: { code: "NOT_FOUND", message: "session not found" } });
163
+ }
164
+ if (record.expiresAt < Date.now()) {
165
+ await this.state.storage.delete(`s:${token}`);
166
+ return jsonResponse(404, { error: { code: "EXPIRED", message: "session expired" } });
167
+ }
168
+ return jsonResponse(200, { token, ...record });
169
+ }
170
+ async handleRevoke(request) {
171
+ const token = request.headers.get(SESSION_TOKEN_HEADER);
172
+ if (!token) {
173
+ return jsonResponse(400, { error: { code: "INVALID_INPUT", message: "token required" } });
174
+ }
175
+ await this.state.storage.delete(`s:${token}`);
176
+ return jsonResponse(200, { ok: true });
177
+ }
178
+ }
179
+
180
+ export { SESSION_DO_TTL_DEFAULT, SESSION_DO_TTL_MAX, SessionDO };
@@ -0,0 +1,146 @@
1
+ const SHARD_REGISTRY_DO_NAME = "__lunora_shard_registry__";
2
+ const STORAGE_KEY = "__tables__";
3
+ const jsonResponse = (status, body) => Response.json(body, {
4
+ headers: { "content-type": "application/json" },
5
+ status
6
+ });
7
+ const readTableShardBody = async (request) => {
8
+ let body;
9
+ try {
10
+ body = await request.json();
11
+ } catch {
12
+ return { kind: "error", response: jsonResponse(400, { error: { code: "BAD_REQUEST", message: "invalid JSON body" } }) };
13
+ }
14
+ const table = typeof body.table === "string" ? body.table.trim() : "";
15
+ const shardKey = typeof body.shardKey === "string" ? body.shardKey.trim() : "";
16
+ if (!table || !shardKey) {
17
+ return {
18
+ kind: "error",
19
+ response: jsonResponse(400, { error: { code: "BAD_REQUEST", message: "table and shardKey required" } })
20
+ };
21
+ }
22
+ return { kind: "ok", value: { shardKey, table } };
23
+ };
24
+ class ShardRegistryDO {
25
+ env;
26
+ state;
27
+ /**
28
+ * In-memory snapshot. The single `__tables__` key is persisted on every
29
+ * mutation; the in-memory copy is the source of truth for reads so
30
+ * `/list` never pays a storage round-trip.
31
+ */
32
+ tables = /* @__PURE__ */ new Map();
33
+ /**
34
+ * Lazy-loaded — populated on the first `fetch` via
35
+ * `blockConcurrencyWhile`. We can't load in the constructor (eslint:
36
+ * `sonarjs/no-async-constructor`) and don't need to: until the first
37
+ * fetch arrives the in-memory map is unread anyway.
38
+ */
39
+ loaded = false;
40
+ constructor(state, env) {
41
+ this.state = state;
42
+ this.env = env;
43
+ }
44
+ async fetch(request) {
45
+ await this.ensureLoaded();
46
+ const url = new URL(request.url);
47
+ if (request.method === "POST" && url.pathname === "/register") {
48
+ return this.handleRegister(request);
49
+ }
50
+ if (request.method === "POST" && url.pathname === "/unregister") {
51
+ return this.handleUnregister(request);
52
+ }
53
+ if (request.method === "GET" && url.pathname === "/list") {
54
+ return this.handleList(url);
55
+ }
56
+ if (request.method === "GET" && url.pathname === "/snapshot") {
57
+ return this.handleSnapshot();
58
+ }
59
+ return jsonResponse(404, { error: { code: "NOT_FOUND", message: `unknown shard-registry route ${request.method} ${url.pathname}` } });
60
+ }
61
+ /**
62
+ * Load the persisted snapshot exactly once. `blockConcurrencyWhile`
63
+ * suspends concurrent fetch dispatches on this DO until the load
64
+ * finishes, so the `loaded` flag doesn't need an additional mutex.
65
+ */
66
+ async ensureLoaded() {
67
+ if (this.loaded) {
68
+ return;
69
+ }
70
+ await this.state.blockConcurrencyWhile(async () => {
71
+ if (this.loaded) {
72
+ return;
73
+ }
74
+ const stored = await this.state.storage.get(STORAGE_KEY);
75
+ if (stored) {
76
+ for (const [table, keys] of Object.entries(stored)) {
77
+ this.tables.set(table, new Set(keys));
78
+ }
79
+ }
80
+ this.loaded = true;
81
+ });
82
+ }
83
+ handleList(url) {
84
+ const table = url.searchParams.get("table");
85
+ if (!table) {
86
+ return jsonResponse(400, { error: { code: "BAD_REQUEST", message: "missing required query parameter: table" } });
87
+ }
88
+ return jsonResponse(200, { shardKeys: [...this.tables.get(table) ?? []] });
89
+ }
90
+ async handleRegister(request) {
91
+ const parsed = await readTableShardBody(request);
92
+ if (parsed.kind === "error") {
93
+ return parsed.response;
94
+ }
95
+ const { shardKey, table } = parsed.value;
96
+ return this.state.blockConcurrencyWhile(async () => {
97
+ let set = this.tables.get(table);
98
+ if (!set) {
99
+ set = /* @__PURE__ */ new Set();
100
+ this.tables.set(table, set);
101
+ }
102
+ if (set.has(shardKey)) {
103
+ return jsonResponse(200, { changed: false, ok: true });
104
+ }
105
+ set.add(shardKey);
106
+ await this.persist();
107
+ return jsonResponse(200, { changed: true, ok: true });
108
+ });
109
+ }
110
+ handleSnapshot() {
111
+ const out = {};
112
+ for (const [table, set] of this.tables) {
113
+ out[table] = [...set];
114
+ }
115
+ return jsonResponse(200, { tables: out });
116
+ }
117
+ async handleUnregister(request) {
118
+ const parsed = await readTableShardBody(request);
119
+ if (parsed.kind === "error") {
120
+ return parsed.response;
121
+ }
122
+ const { shardKey, table } = parsed.value;
123
+ return this.state.blockConcurrencyWhile(async () => {
124
+ const set = this.tables.get(table);
125
+ if (!set?.has(shardKey)) {
126
+ return jsonResponse(200, { changed: false, ok: true });
127
+ }
128
+ set.delete(shardKey);
129
+ if (set.size === 0) {
130
+ this.tables.delete(table);
131
+ }
132
+ await this.persist();
133
+ return jsonResponse(200, { changed: true, ok: true });
134
+ });
135
+ }
136
+ /** Serialize the in-memory map to a single JSON-safe object and put. */
137
+ async persist() {
138
+ const out = {};
139
+ for (const [table, set] of this.tables) {
140
+ out[table] = [...set];
141
+ }
142
+ await this.state.storage.put(STORAGE_KEY, out);
143
+ }
144
+ }
145
+
146
+ export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO };
@@ -0,0 +1,64 @@
1
+ const compareStrings = (a, b) => {
2
+ if (a < b) {
3
+ return -1;
4
+ }
5
+ return a > b ? 1 : 0;
6
+ };
7
+ const aggregateTableName = (table, indexName) => `${table}__agg_${indexName}`;
8
+ const coerceAggregateNumber = (value) => {
9
+ if (typeof value === "number") {
10
+ return Number.isFinite(value) ? value : void 0;
11
+ }
12
+ return void 0;
13
+ };
14
+ const foldAggregateTally = (tallies, encoded, index, record) => {
15
+ const tally = tallies.get(encoded) ?? { count: 0, value: null };
16
+ if (index.op === "count") {
17
+ tally.count += 1;
18
+ tally.value = tally.count;
19
+ tallies.set(encoded, tally);
20
+ return;
21
+ }
22
+ const numeric = coerceAggregateNumber(record[index.field ?? ""]);
23
+ if (index.op === "sum" || index.op === "avg") {
24
+ if (numeric !== void 0) {
25
+ tally.value = (tally.value ?? 0) + numeric;
26
+ tally.count += 1;
27
+ }
28
+ tallies.set(encoded, tally);
29
+ return;
30
+ }
31
+ tally.count += 1;
32
+ if (numeric !== void 0) {
33
+ if (tally.value === null) {
34
+ tally.value = numeric;
35
+ } else {
36
+ tally.value = index.op === "min" ? Math.min(tally.value, numeric) : Math.max(tally.value, numeric);
37
+ }
38
+ }
39
+ tallies.set(encoded, tally);
40
+ };
41
+ const readAggregateValue = (op, row) => {
42
+ if (op === "count") {
43
+ return row?.value ?? 0;
44
+ }
45
+ if (!row || row.count === 0) {
46
+ return null;
47
+ }
48
+ if (op === "avg") {
49
+ return row.value === null ? null : row.value / row.count;
50
+ }
51
+ return row.value;
52
+ };
53
+ const encodeAggregateKey = (by, source) => {
54
+ if (by.length === 0) {
55
+ return "";
56
+ }
57
+ const ordered = {};
58
+ for (const field of [...by].toSorted(compareStrings)) {
59
+ ordered[field] = source[field] ?? null;
60
+ }
61
+ return JSON.stringify(ordered);
62
+ };
63
+
64
+ export { aggregateTableName, coerceAggregateNumber, encodeAggregateKey, foldAggregateTally, readAggregateValue };