@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.
- package/LICENSE.md +105 -0
- package/README.md +115 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +5599 -0
- package/dist/index.d.ts +5599 -0
- package/dist/index.mjs +35 -0
- package/dist/packem_shared/ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs +313 -0
- package/dist/packem_shared/AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs +84 -0
- package/dist/packem_shared/ConflictError-C0STs6bU.mjs +13 -0
- package/dist/packem_shared/CountRlsUnsupportedError-28ZvvwKS.mjs +133 -0
- package/dist/packem_shared/DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs +237 -0
- package/dist/packem_shared/LogBuffer-B_Ezju_N.mjs +37 -0
- package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +10 -0
- package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs +4009 -0
- package/dist/packem_shared/ReactiveCache-ByVzgH3d.mjs +259 -0
- package/dist/packem_shared/SCAN_DEP-DLJF8dsj.mjs +19 -0
- package/dist/packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs +180 -0
- package/dist/packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs +146 -0
- package/dist/packem_shared/aggregateTableName-CxNqY1Sl.mjs +64 -0
- package/dist/packem_shared/applyCdcChanges-Ctdmxmrv.mjs +103 -0
- package/dist/packem_shared/applyOnDelete-CMif2RKw.mjs +165 -0
- package/dist/packem_shared/armRestore-BJk53Ro8.mjs +55 -0
- package/dist/packem_shared/assertFlatPredicate-DyVYReuT.mjs +160 -0
- package/dist/packem_shared/assertReadonly-dDcFE1YZ.mjs +29 -0
- package/dist/packem_shared/assertValidClientId-CBZ1zC96.mjs +1745 -0
- package/dist/packem_shared/backfillAggregateIndexes-BF5eL7kW.mjs +80 -0
- package/dist/packem_shared/buildSecurityAudit-CCAvoFlr.mjs +1 -0
- package/dist/packem_shared/buildSeekWhere-lVsNXSLy.mjs +84 -0
- package/dist/packem_shared/clearCapturedMail-CPpgl-dX.mjs +104 -0
- package/dist/packem_shared/compileWhereSql-CXrhFA3G.mjs +127 -0
- package/dist/packem_shared/createSystemReader-8CzSZP9V.mjs +80 -0
- package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +35 -0
- package/dist/packem_shared/do-exec-5eQy5cEi.mjs +12 -0
- package/dist/packem_shared/do-sql-BCHCWtrD.mjs +87 -0
- package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +1 -0
- package/dist/packem_shared/ensureFunctionMetricsTables-UDNVD7FS.mjs +248 -0
- package/dist/packem_shared/exportShardRows-DZEhUeyI.mjs +156 -0
- package/dist/packem_shared/ftsTableName-BLEMawrp.mjs +38 -0
- package/dist/packem_shared/guardWriter-u3UlnCH5.mjs +128 -0
- package/dist/packem_shared/matchesStaticWhere-CFk6adSu.mjs +54 -0
- package/dist/packem_shared/rank-CrkEIpF4.mjs +102 -0
- package/dist/packem_shared/renderSql-D6eUcn2N.mjs +16 -0
- package/dist/packem_shared/runShardMigrations-C3bn5r93.mjs +103 -0
- package/dist/packem_shared/runTriggers-5N6_Fx0A.mjs +20 -0
- package/dist/packem_shared/security-audit-CucgBice.mjs +158 -0
- package/dist/packem_shared/serveRelationFanout-Clr1a05L.mjs +24 -0
- 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<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 };
|