lecodes-sdk 2.6.0 → 2.8.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 (51) hide show
  1. package/dist/global.d.ts +11 -7
  2. package/dist/types/inject.d.ts +2 -1
  3. package/dist/types/plugin.d.ts +1 -0
  4. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
  5. package/dist/types/runtime/firstCall.d.ts +12 -0
  6. package/dist/types/runtime/gestures.d.ts +52 -0
  7. package/dist/types/runtime/rpc.d.ts +24 -0
  8. package/dist/types/runtime/wire.d.ts +11 -4
  9. package/dist/types/server/channel.d.ts +16 -0
  10. package/dist/types/server/computed.d.ts +59 -0
  11. package/dist/types/server/context.d.ts +13 -1
  12. package/dist/types/server/db/fields.d.ts +2 -1
  13. package/dist/types/server/db/marci/query.d.ts +53 -6
  14. package/dist/types/server/inject.d.ts +2 -0
  15. package/dist/types/server/ordered.d.ts +18 -0
  16. package/dist/types/ui/UIModal.d.ts +5 -3
  17. package/dist/types/version.d.ts +1 -1
  18. package/dist/types.json +1 -1
  19. package/package.json +2 -2
  20. package/src/bridges/tree.d.ts +14 -4
  21. package/src/compile/serverSplit.ts +8 -4
  22. package/src/compile/serverTypes.ts +17 -3
  23. package/src/inject.ts +5 -2
  24. package/src/plugin.ts +3 -1
  25. package/src/plugins/gen/ar/sdk/ar.ts +59 -14
  26. package/src/runtime/firstCall.ts +28 -0
  27. package/src/runtime/gestures.ts +190 -0
  28. package/src/runtime/rpc.ts +141 -12
  29. package/src/runtime/wire.ts +11 -6
  30. package/src/server/channel.ts +32 -0
  31. package/src/server/channelHub.ts +246 -16
  32. package/src/server/clock.ts +50 -0
  33. package/src/server/computed.ts +84 -0
  34. package/src/server/computedHost.ts +413 -0
  35. package/src/server/context.ts +14 -2
  36. package/src/server/db/defineDb.ts +47 -7
  37. package/src/server/db/fields.ts +2 -1
  38. package/src/server/db/httpTransport.ts +37 -2
  39. package/src/server/db/httpWatch.ts +180 -0
  40. package/src/server/db/marci/query.ts +112 -7
  41. package/src/server/db/watch.ts +160 -0
  42. package/src/server/files/host.ts +2 -1
  43. package/src/server/host.ts +19 -4
  44. package/src/server/inject.ts +6 -2
  45. package/src/server/ordered.ts +41 -0
  46. package/src/server/rateLimit.ts +72 -0
  47. package/src/server/runtime.ts +127 -9
  48. package/src/ui/UIModal.ts +44 -9
  49. package/src/ui/UINode.ts +4 -0
  50. package/src/ui/tree.ts +8 -0
  51. package/src/version.ts +1 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `WatchSource` (./watch.ts) over marcidb-server's watch socket — the source the runner gives every
3
+ * project worker beside its HTTP transport (./httpTransport.ts). One WebSocket per database,
4
+ * `GET <baseUrl>/$watch`, the bearer token as on every other route; JSON text frames:
5
+ *
6
+ * → { "watch": 7, "op": { "model": "Room", "action": "findMany", "query": {…} } }
7
+ * ← { "ok": 7 } | { "err": 7, "message": "…" }
8
+ * → { "unwatch": 7 }
9
+ * ← { "changed": [7, 9] } one frame per commit, an id at most once in it
10
+ *
11
+ * The ids are ours. The socket opens with the first watch and closes a few seconds after the last
12
+ * `unwatch`. When it drops while watches are live it reconnects with back-off, sends every watch again
13
+ * and, once the engine has answered them all, says every one changed: what was committed meanwhile was
14
+ * not signalled. A socket that never opened (an engine without `$watch`, a refused token) fails the
15
+ * watches waiting for it with the reason.
16
+ *
17
+ * Host-side code (Bun): the WebSocket is the platform's — Bun's takes `headers`. Pass `WebSocket`
18
+ * explicitly when the global one might be patched by code in the same realm (the runner captures it
19
+ * before it evaluates a bundle, as it does `fetch`).
20
+ */
21
+
22
+ import type { WatchOp, WatchSource } from "./watch"
23
+
24
+ type Socket = {
25
+ readyState: number
26
+ send(data: string): void
27
+ close(): void
28
+ onopen: ((ev: unknown) => void) | null
29
+ onmessage: ((ev: { data: unknown }) => void) | null
30
+ onclose: ((ev: unknown) => void) | null
31
+ onerror: ((ev: unknown) => void) | null
32
+ }
33
+ export type WatchSocketCtor = new (url: string, options?: { headers?: Record<string, string> }) => Socket
34
+
35
+ export type MarciWatchOptions = {
36
+ token?: string | null
37
+ WebSocket?: WatchSocketCtor
38
+ /** How long the socket outlives its last watch (ms). */
39
+ lingerMs?: number
40
+ /** How long a watch waits for the engine's answer before it fails (ms). */
41
+ answerMs?: number
42
+ }
43
+
44
+ const BACKOFF_MS = [250, 1000, 2000, 5000, 10000]
45
+
46
+ export const marciHttpWatchSource = (baseUrl: string, options: MarciWatchOptions = {}): WatchSource => {
47
+ const url = baseUrl.replace(/\/+$/, "").replace(/^http/, "ws") + "/$watch"
48
+ const Ctor = options.WebSocket ?? (globalThis.WebSocket as unknown as WatchSocketCtor)
49
+ const lingerMs = options.lingerMs ?? 3000
50
+ const answerMs = options.answerMs ?? 10_000
51
+
52
+ /** every live watch, answered or not: what a reconnect sends again */
53
+ const ops = new Map<number, WatchOp>()
54
+ /** the watches the engine has answered `ok` */
55
+ const held = new Set<number>()
56
+ /** the watches waiting for their first answer */
57
+ const waiting = new Map<number, { resolve: (id: number) => void, reject: (e: Error) => void, timer: ReturnType<typeof setTimeout> }>()
58
+ /** after a reconnect: the held watches sent again and not answered yet — "changed" goes out when it empties */
59
+ let resync: Set<number> | null = null
60
+ let resynced: number[] = []
61
+ let listener: ((ids: number[]) => void) | null = null
62
+ let ws: Socket | null = null
63
+ let open = false
64
+ let attempts = 0
65
+ let retry: ReturnType<typeof setTimeout> | null = null
66
+ let linger: ReturnType<typeof setTimeout> | null = null
67
+ let closed = false
68
+ let next = 1
69
+
70
+ const send = (frame: unknown) => { if (open) ws!.send(JSON.stringify(frame)) }
71
+ const fail = (id: number, message: string) => {
72
+ const w = waiting.get(id)
73
+ ops.delete(id)
74
+ if (!w) return
75
+ waiting.delete(id)
76
+ clearTimeout(w.timer)
77
+ w.reject(new Error(message))
78
+ }
79
+ const answered = (id: number) => {
80
+ if (!resync?.delete(id) || resync.size) return
81
+ resync = null
82
+ const ids = resynced.filter(i => held.has(i))
83
+ resynced = []
84
+ if (ids.length) listener?.(ids)
85
+ }
86
+
87
+ const connect = () => {
88
+ if (ws || retry !== null || closed) return
89
+ const socket = new Ctor(url, { headers: options.token ? { authorization: `Bearer ${options.token}` } : {} })
90
+ ws = socket
91
+ let opened = false
92
+ socket.onopen = () => {
93
+ if (ws !== socket) return
94
+ const again = attempts > 0
95
+ opened = true
96
+ open = true
97
+ attempts = 0
98
+ if (again && held.size) { resync = new Set(held); resynced = [...held] }
99
+ for (const [id, op] of ops) socket.send(JSON.stringify({ watch: id, op }))
100
+ }
101
+ socket.onmessage = (ev) => {
102
+ let msg: { ok?: number, err?: number, message?: string, changed?: number[] }
103
+ try { msg = JSON.parse(String(ev.data)) } catch { return }
104
+ if (typeof msg.ok === "number") {
105
+ const id = msg.ok
106
+ const w = waiting.get(id)
107
+ if (w) { waiting.delete(id); clearTimeout(w.timer); if (ops.has(id)) { held.add(id); w.resolve(id) } }
108
+ answered(id)
109
+ } else if (typeof msg.err === "number") {
110
+ const id = msg.err
111
+ // a watch the engine takes no more (a migration made its query invalid): its subscriber reads
112
+ // again and hears the read's own error
113
+ if (held.delete(id)) { ops.delete(id); answered(id); listener?.([id]); return }
114
+ fail(id, `marcidb refused the watch: ${msg.message ?? "unknown error"}`)
115
+ } else if (Array.isArray(msg.changed)) {
116
+ const ids = msg.changed.filter(id => held.has(id))
117
+ if (ids.length) listener?.(ids)
118
+ }
119
+ }
120
+ const down = () => {
121
+ if (ws !== socket) return
122
+ ws = null
123
+ open = false
124
+ resync = null
125
+ // never opened: nothing will answer the watches waiting for it
126
+ if (!opened) for (const id of [...waiting.keys()]) fail(id, `marcidb: no watch socket at ${url} (an engine without $watch, or the token refused)`)
127
+ if (closed || ops.size === 0) { attempts = 0; return }
128
+ const delay = BACKOFF_MS[Math.min(attempts++, BACKOFF_MS.length - 1)]
129
+ retry = setTimeout(() => { retry = null; connect() }, delay)
130
+ }
131
+ socket.onclose = down
132
+ socket.onerror = down
133
+ }
134
+
135
+ const idle = () => {
136
+ if (ops.size || linger !== null) return
137
+ linger = setTimeout(() => {
138
+ linger = null
139
+ if (ops.size || !ws) return
140
+ const socket = ws
141
+ ws = null
142
+ open = false
143
+ socket.close()
144
+ }, lingerMs)
145
+ }
146
+
147
+ return {
148
+ watch(op) {
149
+ if (closed) return Promise.reject(new Error("db watch: the source is closed"))
150
+ const id = next++
151
+ if (linger !== null) { clearTimeout(linger); linger = null }
152
+ return new Promise<number>((resolve, reject) => {
153
+ const timer = setTimeout(() => fail(id, `marcidb did not answer the watch within ${answerMs} ms`), answerMs)
154
+ waiting.set(id, { resolve, reject, timer })
155
+ ops.set(id, op)
156
+ if (open) send({ watch: id, op })
157
+ else connect()
158
+ })
159
+ },
160
+ unwatch(id) {
161
+ if (!ops.delete(id)) return
162
+ held.delete(id)
163
+ send({ unwatch: id })
164
+ idle()
165
+ },
166
+ onChanged(cb) { listener = cb },
167
+ close() {
168
+ closed = true
169
+ if (retry !== null) { clearTimeout(retry); retry = null }
170
+ if (linger !== null) { clearTimeout(linger); linger = null }
171
+ for (const id of [...waiting.keys()]) fail(id, "db watch: the source is closed")
172
+ ops.clear()
173
+ held.clear()
174
+ const socket = ws
175
+ ws = null
176
+ open = false
177
+ socket?.close()
178
+ },
179
+ }
180
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  // The generic query layer of the marcidb client: the query-language types every generated client is built
5
5
  // on (where / update operators, `GetResult`, `Op`, the query builder `Query<T>`) and the builder runtime
6
- // (`createQueryLayer`). Everything here is schema-agnostic — the codegen (crates/marcidb-ts) emits the
6
+ // (`createQueryLayer`, `op.watch` included). Everything here is schema-agnostic — the codegen (crates/marcidb-ts) emits the
7
7
  // per-model type bags and MODELS metadata this file is parametrised by.
8
8
  //
9
9
  // Keep it dependency-free: besides shipping in `marcidb-client/runtime`, this file is vendored verbatim
@@ -181,6 +181,37 @@ export type Settles<T> = PromiseLike<T> & {
181
181
  }
182
182
  export type Op<T> = Settles<T> & { readonly [__op]: T }
183
183
 
184
+ /** A running watch — see `ReadOp.watch`. */
185
+ export interface Watch {
186
+ /** Stops it: no callback after this returns. Idempotent. */
187
+ close(): void
188
+ }
189
+ export type WatchOptions = {
190
+ /** Gets what a read of the watch failed with (an op the database refuses — after a migration, say),
191
+ * and what the callback threw. Without it, they are logged. */
192
+ error?: (error: unknown) => void
193
+ }
194
+ /** A read (`select`, `first`, `count`, `aggregate`): an op that can also be watched. */
195
+ export type ReadOp<T> = Op<T> & {
196
+ /**
197
+ * Calls `callback` with the result at once, then again each time a commit changed it. The database signals
198
+ * every commit that may have changed it; the client reads again and calls back only when the result
199
+ * differs (as JSON) from the one it delivered last. Signals that arrive during a read make one more read.
200
+ * A dropped connection is reopened and read again by the client — nothing to handle outside.
201
+ */
202
+ watch(callback: (result: T) => void, options?: WatchOptions): Watch
203
+ }
204
+ /** What a transport tells a watch it holds (internal: `op.watch` is built on it). */
205
+ export type WatchListener = {
206
+ /** A commit may have changed the result — or the transport recovered a lost connection. */
207
+ changed(): void
208
+ /** The database refused the op when the transport registered it again. */
209
+ failed(error: unknown): void
210
+ }
211
+ /** A watch registered with a transport: `ready` settles once the database has it (it rejects with the
212
+ * refusal), `close` drops it. */
213
+ export type WatchRegistration = { ready: Promise<void>, close(): void }
214
+
184
215
  // A transport-neutral operation descriptor and the pluggable transport that runs it. The HTTP transport
185
216
  // is selected by passing a URL string; marcidb-embedded provides an in-process FFI transport.
186
217
  export type MarciOp = { model: string, action: string, query?: any, data?: any, id?: any, key?: any, onInsert?: any, journal?: JournalArgs }
@@ -189,6 +220,14 @@ export type JournalArgs = { name: string, on?: readonly JournalOp[], after?: num
189
220
  export type MarciTransport = {
190
221
  exec(op: MarciOp): Promise<any>
191
222
  batch(ops: MarciOp[]): Promise<any[]>
223
+ /** The binary read fast path, if the transport has one. `schemaHash` is the fingerprint of the schema the
224
+ * calling client was generated from; the transport answers with the binary result buffer only for a
225
+ * database on the same schema. `{ json }` is an already-parsed JSON answer (HTTP, when the server declined
226
+ * binary); `null` means "no binary answer" and the client falls back to `exec`. */
227
+ queryBinary?(op: MarciOp, schemaHash: string): Promise<Uint8Array | { json: any } | null>
228
+ /** Watches, if the transport has them: registers the read `op` and tells `listener` whenever a commit may
229
+ * have changed its result. Only signals — the re-read and the comparison are the query layer's. */
230
+ watch?(op: MarciOp, listener: WatchListener): WatchRegistration
192
231
  }
193
232
 
194
233
  // ───────────────────────────── query builder ─────────────────────────────
@@ -237,6 +276,8 @@ export type IdArg<I> = I | BareId<I>
237
276
  */
238
277
  export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles<Rows<T, Sel>[]> {
239
278
  readonly [__op]: Rows<T, Sel>[]
279
+ /** The rows now, then each time a commit changed them — see `ReadOp.watch`. */
280
+ watch(callback: (rows: Rows<T, Sel>[]) => void, options?: WatchOptions): Watch
240
281
  readonly [__sub]: T["name"]
241
282
  readonly [__sel]: Effective<T, Sel>
242
283
  /** Filter (marcidb `$where`). Repeated calls are ANDed. */
@@ -257,13 +298,13 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles
257
298
  */
258
299
  select<S extends T["query"] = T["scalars"]>(shape?: S): Query<T, S>
259
300
  /** The first matching row or `null`. */
260
- first(): Op<Rows<T, Sel> | null>
301
+ first(): ReadOp<Rows<T, Sel> | null>
261
302
  /** Row count; inside a select it becomes `{ count }` for the relation. */
262
- count(): Op<number> & Sub<T["name"], { $count: true }>
303
+ count(): ReadOp<number> & Sub<T["name"], { $count: true }>
263
304
  /** @deprecated pass the filter through the chain: `db.user.where(w).count()` (removed in the next minor). */
264
- count(query: { $where?: T["where"] }): Op<number> & Sub<T["name"], { $count: true }>
305
+ count(query: { $where?: T["where"] }): ReadOp<number> & Sub<T["name"], { $count: true }>
265
306
  // `NoInfer`: as a select value the contextual type is `Sub<Name, any>`, which would otherwise win the inference of `A`
266
- aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>
307
+ aggregate<A extends T["aggregate"]>(query: A): ReadOp<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>
267
308
 
268
309
  insert(data: T["insert"]): Op<T["id"]>
269
310
  /** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
@@ -289,9 +330,9 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles
289
330
 
290
331
  // ── object form (deprecated): one query object (select shape + $where/$order/$limit/$skip/$cursor)
291
332
  /** @deprecated use `.select(query)` — it takes the same object, merged with the chain (removed in the next minor). */
292
- findMany<Q extends T["query"] = {}>(query?: Q): Op<Rows<T, Q>[]>
333
+ findMany<Q extends T["query"] = {}>(query?: Q): ReadOp<Rows<T, Q>[]>
293
334
  /** @deprecated use `.select(query).first()` (removed in the next minor). */
294
- findFirst<Q extends T["query"] = {}>(query?: Q): Op<Rows<T, Q> | null>
335
+ findFirst<Q extends T["query"] = {}>(query?: Q): ReadOp<Rows<T, Q> | null>
295
336
  }
296
337
 
297
338
  // ───────────────────────────── journals ─────────────────────────────
@@ -340,8 +381,12 @@ export type QueryLayerOptions = {
340
381
  /** Hook for the `onInsert` part of an `upsert` — the fields used only when the row is created (the lecodes SDK
341
382
  * puts a uuid id here). Receives what the caller gave (or undefined), returns what is sent. */
342
383
  prepareUpsert?(model: string, onInsert: any): any;
384
+ /** The transport's watches (`MarciTransport.watch`); without it `op.watch` throws. */
385
+ watch?(op: MarciOp, listener: WatchListener): WatchRegistration;
343
386
  };
344
387
 
388
+ const READ_ACTIONS = ["findMany", "findFirst", "count", "aggregate"];
389
+
345
390
  const AGGREGATE_KEYS = ["$count", "$sum", "$avg", "$min", "$max"];
346
391
  const and = (a: any, b: any): any => (a ? (b ? { $and: [a, b] } : a) : b);
347
392
 
@@ -355,6 +400,64 @@ type QueryState = { shape?: Record<string, any>; where?: any; order?: any; limit
355
400
  export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor: MarciOp) => any; collection: (model: string) => any } {
356
401
  const { models, run } = options;
357
402
 
403
+ // `op.watch(callback, { error })` — one code for every transport: the transport only says "may have
404
+ // changed"; reading again, comparing with what was delivered and coalescing happen here.
405
+ const watch = (descriptor: MarciOp, callback: (result: any) => void, o?: WatchOptions): Watch => {
406
+ if (!READ_ACTIONS.includes(descriptor.action)) {
407
+ throw new Error(`marcidb: ${descriptor.model}.${descriptor.action} is a write — only reads can be watched`);
408
+ }
409
+ if (!options.watch) throw new Error("marcidb: this transport cannot watch");
410
+ let closed = false;
411
+ let reading = false;
412
+ let again = false;
413
+ let last: string | undefined; // the last result delivered, as JSON
414
+ const fail = (e: unknown) => {
415
+ if (closed) return;
416
+ if (o?.error) o.error(e);
417
+ else console.error(`marcidb: a watch of ${descriptor.model}.${descriptor.action} failed:`, e);
418
+ };
419
+ const read = async (): Promise<void> => {
420
+ if (closed) return;
421
+ if (reading) {
422
+ again = true; // one more read after this one, however many signals came meanwhile
423
+ return;
424
+ }
425
+ reading = true;
426
+ try {
427
+ do {
428
+ again = false;
429
+ let result: any;
430
+ try {
431
+ result = await run(descriptor);
432
+ } catch (e) {
433
+ fail(e);
434
+ continue;
435
+ }
436
+ const json = JSON.stringify(result);
437
+ if (closed || json === last) continue;
438
+ last = json;
439
+ try {
440
+ callback(result);
441
+ } catch (e) {
442
+ fail(e);
443
+ }
444
+ } while (again && !closed);
445
+ } finally {
446
+ reading = false;
447
+ }
448
+ };
449
+ // Registered first, read second: a commit between the two is signalled, not lost.
450
+ const registration = options.watch(descriptor, { changed: () => void read(), failed: fail });
451
+ registration.ready.then(read, fail);
452
+ return {
453
+ close() {
454
+ if (closed) return;
455
+ closed = true;
456
+ registration.close();
457
+ },
458
+ };
459
+ };
460
+
358
461
  // Lazily-executed operation: `await` runs it through the transport as a single op,
359
462
  // while `$transaction` takes only the `__op` descriptor and sends them as one batch.
360
463
  const op = (descriptor: MarciOp): any => ({
@@ -362,6 +465,7 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
362
465
  then: (onFulfilled?: any, onRejected?: any) => run(descriptor).then(onFulfilled, onRejected),
363
466
  catch: (onRejected?: any) => run(descriptor).catch(onRejected),
364
467
  finally: (onFinally?: any) => run(descriptor).finally(onFinally),
468
+ watch: (callback: (result: any) => void, o?: WatchOptions) => watch(descriptor, callback, o),
365
469
  });
366
470
 
367
471
  // A field key is anything that isn't a `$`-clause. A shape without field keys selects id + every scalar
@@ -460,6 +564,7 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
460
564
  then: (onFulfilled?: any, onRejected?: any) => run(findMany()).then(onFulfilled, onRejected),
461
565
  catch: (onRejected?: any) => run(findMany()).catch(onRejected),
462
566
  finally: (onFinally?: any) => run(findMany()).finally(onFinally),
567
+ watch: (callback: (rows: any) => void, o?: WatchOptions) => watch(findMany(), callback, o),
463
568
 
464
569
  where: (where: any) => make({ ...st, where: and(st.where, where) }),
465
570
  order: (field: any, direction?: "asc" | "desc") => {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The database's half of `computed` (../computed.ts): a WATCH is a read the engine keeps an eye on.
3
+ * After a commit that may have changed its result the engine says so — and nothing more: it carries no
4
+ * rows, whoever watches reads again (marci-db's `watch`, docs/plans/multiplayer-plan.md D1). A false
5
+ * "changed" costs one read, a missed one a stale screen, so the engine over-approximates.
6
+ *
7
+ * const source = marciHttpWatchSource(dbUrl, { token }) // ./httpWatch.ts
8
+ * source.onChanged((ids) => …) // these results may differ now
9
+ * const id = await source.watch({ model: "Room", action: "findMany", query }) // the engine holds it
10
+ * source.unwatch(id)
11
+ *
12
+ * `watch` resolves once the engine holds the watch: a commit after that is signalled. So the host
13
+ * watches a read BEFORE it sends it (../computedHost.ts) — a write that lands between the two is either
14
+ * in the read's answer or signalled, never lost. Watches are ephemeral: a source whose connection drops
15
+ * registers them all again and then says every one changed.
16
+ *
17
+ * Host-side only (it is never bundled). Three sources stand behind the interface: the HTTP one
18
+ * (./httpWatch.ts, the runner's: marcidb-server's `GET /<db>/$watch` socket), the embedded one
19
+ * (marcidb-embedded's handle, `lecodes dev` / `lecodes test`, below), and a fake for tests that fires
20
+ * when it is told to.
21
+ */
22
+
23
+ import { AsyncLocalStorage } from "node:async_hooks"
24
+ import type { MarciOp, WatchListener, WatchRegistration } from "./marci/query"
25
+
26
+ /** A read as the engine watches it: the op the db client sends, minus what a read does not have. */
27
+ export type WatchOp = { model: string, action: string, query: unknown }
28
+
29
+ /** The ops a watch takes — and the only ones a computed may send. */
30
+ export const READ_ACTIONS: ReadonlySet<string> = new Set(["findMany", "findFirst", "count", "aggregate"])
31
+ export const isReadOp = (op: Pick<MarciOp, "action">): boolean => READ_ACTIONS.has(op.action)
32
+
33
+ export interface WatchSource {
34
+ /** Watch the result of `op`; resolves with the watch's id once the engine holds it. Rejects when the
35
+ * engine refuses the query (it would refuse the read too) or cannot be reached. */
36
+ watch(op: WatchOp): Promise<number>
37
+ /** Stop watching (an id it does not hold is ignored). */
38
+ unwatch(id: number): void
39
+ /** Where "these watches' results may have changed" goes: one listener, set once. */
40
+ onChanged(listener: (ids: number[]) => void): void
41
+ /** Drop every watch and the connection (the worker is going away). */
42
+ close(): void
43
+ }
44
+
45
+ // ───────────────────────────── a fake, for tests ─────────────────────────────
46
+
47
+ export type FakeWatchSource = WatchSource & {
48
+ /** The live watches: id → op. */
49
+ readonly watches: ReadonlyMap<number, WatchOp>
50
+ /** Every `watch` and `unwatch`, in order: `+1 Room.findMany`, `-1`. */
51
+ readonly log: string[]
52
+ /** Say these watches changed — every live one when none is named. Synchronous. */
53
+ fire(...ids: number[]): void
54
+ /** Say every watch of `model` changed: what a commit that wrote the model does, coarsely. */
55
+ fireModel(model: string): void
56
+ /** Refuse a query the way the engine would (`watch` rejects with the message); null = take it. */
57
+ refuse: ((op: WatchOp) => string | null) | null
58
+ }
59
+
60
+ export const createFakeWatchSource = (): FakeWatchSource => {
61
+ const watches = new Map<number, WatchOp>()
62
+ let listener: ((ids: number[]) => void) | null = null
63
+ let next = 1
64
+ const fake: FakeWatchSource = {
65
+ watches,
66
+ log: [],
67
+ refuse: null,
68
+ watch: async (op) => {
69
+ const refused = fake.refuse?.(op)
70
+ if (refused) throw new Error(refused)
71
+ const id = next++
72
+ watches.set(id, op)
73
+ fake.log.push(`+${id} ${op.model}.${op.action}`)
74
+ return id
75
+ },
76
+ unwatch: (id) => {
77
+ if (!watches.delete(id)) return
78
+ fake.log.push(`-${id}`)
79
+ },
80
+ onChanged: (cb) => { listener = cb },
81
+ close: () => { watches.clear() },
82
+ fire: (...ids) => {
83
+ const hit = (ids.length ? ids : [...watches.keys()]).filter(id => watches.has(id))
84
+ if (hit.length) listener?.(hit)
85
+ },
86
+ fireModel: (model) => {
87
+ const hit = [...watches].filter(([, op]) => op.model === model).map(([id]) => id)
88
+ if (hit.length) listener?.(hit)
89
+ },
90
+ }
91
+ return fake
92
+ }
93
+
94
+ // ───────────────────────────── marcidb-embedded ─────────────────────────────
95
+
96
+ /** What the embedded source needs of a marcidb-embedded handle (`openDatabase(dir)`): its `watch`, which the
97
+ * package has from the engine's ABI 4 (marcidb-embedded 0.18). */
98
+ export type EmbeddedWatchHandle = { watch?(op: MarciOp, listener: WatchListener): WatchRegistration }
99
+
100
+ /**
101
+ * Run `fn` in a clean async context — the one this module was loaded in — instead of the caller's. For the
102
+ * timers and sockets a watch makes: marcidb-embedded's poll timer is made by the handle's first watch and
103
+ * every watch's callback runs in the context it was made in (the app's own `op.watch` too), which must not be
104
+ * a computed's run or a request. Where the platform has no snapshot (an old Node), the caller's.
105
+ */
106
+ export const outsideRequests: <R>(fn: () => R) => R =
107
+ (AsyncLocalStorage as unknown as { snapshot?: () => <R>(fn: () => R) => R }).snapshot?.() ?? ((fn) => fn())
108
+
109
+ /**
110
+ * marcidb-embedded's watches (`lecodes dev`, `lecodes test`): the handle's `watch(op, listener)`. The engine
111
+ * keeps the ids a commit touched; the package polls them every 50 ms while a watch is open and calls each
112
+ * one's listener — the ids of one poll reach `onChanged` together. `ready` is settled at once (the engine
113
+ * takes or refuses the query on the spot). `db` is read at each watch: the host opens the database after it
114
+ * made the source. A package from before the engine had watches (no `watch` on the handle) refuses every
115
+ * watch with the reason, so a computed that reads the database is refused when subscribed to (its call works).
116
+ */
117
+ export const embeddedWatchSource = (db: () => EmbeddedWatchHandle | null): WatchSource => {
118
+ const held = new Map<number, WatchRegistration>()
119
+ let listener: ((ids: number[]) => void) | null = null
120
+ let batch: number[] | null = null
121
+ let next = 1
122
+ const changed = (id: number) => {
123
+ if (!held.has(id)) return
124
+ if (!batch) {
125
+ batch = []
126
+ queueMicrotask(() => { const ids = batch!; batch = null; if (ids.length) listener?.(ids.filter((i) => held.has(i))) })
127
+ }
128
+ if (!batch.includes(id)) batch.push(id)
129
+ }
130
+ return {
131
+ async watch(op) {
132
+ const handle = db()
133
+ if (!handle) throw new Error("db watch: the database is not open")
134
+ if (typeof handle.watch !== "function") throw new Error("db watch: this marcidb-embedded has no watch (it came with marcidb-embedded 0.18) — update it, so a computed that reads the database can be subscribed to under `lecodes dev` / `lecodes test`; call it once meanwhile")
135
+ const id = next++
136
+ // a watch the engine took no more is read again: the read says why (as the HTTP source does)
137
+ const reg = outsideRequests(() => handle.watch!({ model: op.model, action: op.action, query: op.query }, { changed: () => changed(id), failed: () => changed(id) }))
138
+ held.set(id, reg)
139
+ try {
140
+ await reg.ready
141
+ } catch (e) {
142
+ held.delete(id)
143
+ reg.close()
144
+ throw e
145
+ }
146
+ return id
147
+ },
148
+ unwatch(id) {
149
+ const reg = held.get(id)
150
+ if (!reg) return
151
+ held.delete(id)
152
+ reg.close()
153
+ },
154
+ onChanged(cb) { listener = cb },
155
+ close() {
156
+ for (const reg of held.values()) reg.close()
157
+ held.clear()
158
+ },
159
+ }
160
+ }
@@ -400,7 +400,8 @@ export const createFilesHost = (cfg: FilesHostConfig): FilesHost => {
400
400
  }
401
401
  const seam: FilesSeam = {
402
402
  uploaded: (value) => typeof value === "object" && value !== null ? uploaded.get(value) : undefined,
403
- url: (id, name) => `${currentRequest()?.filesBase ?? ""}/files/${id}/${encodeURIComponent(name)}`,
403
+ // the host's read, not the code's: a computed that returns a file is still every subscriber's (the base is in its key)
404
+ url: (id, name) => `${currentRequest(true)?.filesBase ?? ""}/files/${id}/${encodeURIComponent(name)}`,
404
405
  image,
405
406
  dirty: false,
406
407
  }
@@ -6,20 +6,32 @@
6
6
  */
7
7
 
8
8
  export {
9
- loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, setChannelPublisher, setRequestProvider, runSeed, SeedError,
9
+ loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, createComputedHost, setChannelPublisher, setPresenceProvider, setRequestProvider, runSeed, SeedError,
10
10
  } from "./runtime"
11
- export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration, SeedOutcome } from "./runtime"
11
+ export type { LoadedServer, InvokeResult, InvokerOptions, ComputedHostOptions, ServerRegistration, SeedOutcome } from "./runtime"
12
+ export { ComputedWriteError } from "./computedHost"
13
+ export type { ComputedHost } from "./computedHost"
14
+ export { createFakeWatchSource, embeddedWatchSource, isReadOp, outsideRequests, READ_ACTIONS } from "./db/watch"
15
+ export type { EmbeddedWatchHandle, FakeWatchSource, WatchOp, WatchSource } from "./db/watch"
16
+ export { marciHttpWatchSource } from "./db/httpWatch"
17
+ export type { MarciWatchOptions, WatchSocketCtor } from "./db/httpWatch"
12
18
  export type { RequestContext } from "./context"
13
19
  export { setEnv, EnvError, ENV_NAME, PUBLIC_ENV_PREFIX, isPublicEnvName } from "./env"
14
20
  export { TRUSTED_CA_FILE, trustedCaEnv } from "./trustedCa"
21
+ export { setServerClock, serverClockOffset } from "./clock"
22
+ export { firstCallGate } from "../runtime/firstCall"
23
+ export type { FirstCallGate } from "../runtime/firstCall"
15
24
  export type { Env } from "./env"
16
- export { createChannelHub } from "./channelHub"
25
+ export { createChannelHub, PRESENCE_GRACE_MS } from "./channelHub"
26
+ export { callerKey, createRateLimiter, RATE_LIMITS, TOO_MANY_CALLS } from "./rateLimit"
27
+ export type { RateLimit, RateLimiter } from "./rateLimit"
28
+ export type { PresenceAsk } from "./channel"
17
29
  export type { ChannelHub, ChannelHubOptions, HubConnection, HubFanout } from "./channelHub"
18
30
  export type { ChannelGroup, ClientFrame, ServerFrame } from "../runtime/wire"
19
31
  export { setDbTransport } from "./db/defineDb"
20
32
  export { marciHttpTransport, marciRequest, encodeMarciId, MarciHttpError } from "./db/httpTransport"
21
33
  export type { MarciHttpOptions } from "./db/httpTransport"
22
- export type { MarciOp, MarciTransport } from "./db/marci/query"
34
+ export type { MarciOp, MarciTransport, WatchListener, WatchRegistration } from "./db/marci/query"
23
35
  export type { ServerManifest, ServerExport } from "../compile/serverSplit"
24
36
  export { validateArgs, ValidationError } from "./validate"
25
37
  export type { ParamSchema, Schema as ParamSchemaNode } from "./validate"
@@ -57,6 +69,8 @@ export type ServerDescription = {
57
69
  * A `t.file()` field itself is described as `kind: "file"`. */
58
70
  models: Record<string, { fields: Record<string, FieldDescription>, auth?: true, files?: true }>
59
71
  endpoints: { id: string, params: unknown[] | null }[]
72
+ /** The computeds (`computed()` exports), by id. Each is in `endpoints` too: its one-shot call. */
73
+ computeds: string[]
60
74
  channels: string[]
61
75
  /** The push channels (`pushChannel()` exports), by id. */
62
76
  pushChannels: string[]
@@ -109,6 +123,7 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
109
123
  marci: db?.$schema ?? "",
110
124
  models,
111
125
  endpoints: [...server.endpoints.keys()].map(id => ({ id, params: params[id] ?? null })),
126
+ computeds: [...server.computeds.keys()],
112
127
  channels: [...server.channels.keys()],
113
128
  pushChannels: [...server.pushChannels.keys()],
114
129
  auth: { model: db?.$auth?.model ?? null },
@@ -3,16 +3,20 @@
3
3
  * chisel injects these into the server bundle exactly like ./inject.ts does for the client bundle;
4
4
  * nothing here may reach the client bundle. Keep this the ONLY file that exports server globals.
5
5
  *
6
- * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, pushChannel, request, env. Sign-in is
7
- * not a global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`.
6
+ * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, computed, pushChannel, request, ordered, env.
7
+ * Sign-in is not a global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`. `computed` and
8
+ * `ref` are names of the app's globals too — another value each side, one declaration of both in the project's
9
+ * types (build-types.ts).
8
10
  */
9
11
 
10
12
  export { t, model, defineDb, ref } from "./db"
11
13
  export { ApiError } from "./errors"
12
14
  export { env, type Env } from "./env"
13
15
  export { channel } from "./channel"
16
+ export { computed } from "./computed"
14
17
  export { pushChannel, type PushChannel, type PushMessage, type PushNotification, type PushTarget } from "./pushChannel"
15
18
  export { request } from "./context"
19
+ export { ordered } from "./ordered"
16
20
  export type { StoredFile, StoredImage } from "./files/models"
17
21
  /** The value an SVG source is to the client (`assetIcon(…)` compiles to `SvgSource(…)`): a shared module of
18
22
  * the design that names an icon per row loads on the server too. The server draws nothing with it. */
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `ordered(key, fn)` — calls with one key run one after another (server global). A worker runs its
3
+ * calls concurrently and every `await` lets another one in, so a read-check-write under a key (a
4
+ * room's code) needs the key's other calls to wait: a chain of promises per key, in this worker. A
5
+ * key is dropped when its chain drains; different keys run in parallel; nothing else is kept (the
6
+ * state stays in the database). Honest because one worker serves a project at a time — the runner
7
+ * lets the new worker of a deploy take calls only once the old one's are done (tools/runner/src/project.ts).
8
+ *
9
+ * Two rules: keep the body short (the key's other callers wait; a call has 30 s), and no `ordered` of a
10
+ * key from inside its own body (it would wait for itself) — that rejects. The keys a body holds follow
11
+ * its async chain in an AsyncLocalStorage: a body's sibling (another call of the same request) is not
12
+ * taken for re-entry, nor is a timer that fires after the body ended. The class comes over a global seam
13
+ * the host runtime installs (./runtime.ts): the bundle carries its own copy of this module and imports
14
+ * nothing. Without the seam the queue still works; only re-entry is not caught.
15
+ */
16
+
17
+ export type OrderedKey = string | number
18
+ type Hold = { key: OrderedKey, done: boolean }
19
+ type Scope = { getStore(): readonly Hold[] | undefined, run<R>(held: readonly Hold[], fn: () => R): R }
20
+
21
+ const seam = globalThis as unknown as { __lecodesAsyncLocalStorage?: new () => Scope }
22
+ const chains = new Map<OrderedKey, Promise<void>>()
23
+ let scope: Scope | undefined
24
+
25
+ /** Run `fn` once every earlier call of `key` has finished; its result (or failure) is the call's. */
26
+ export const ordered = <T>(key: OrderedKey, fn: () => T | Promise<T>): Promise<T> => {
27
+ scope ??= seam.__lecodesAsyncLocalStorage && new seam.__lecodesAsyncLocalStorage()
28
+ const held = scope?.getStore() ?? []
29
+ if (held.some(h => h.key === key && !h.done)) {
30
+ return Promise.reject(new Error(`ordered(${JSON.stringify(key)}) inside its own body would wait for itself — call that code directly`))
31
+ }
32
+ const hold: Hold = { key, done: false }
33
+ const run = (chains.get(key) ?? Promise.resolve()).then(() => scope ? scope.run([...held, hold], fn) : fn())
34
+ const tail = run.then(() => { hold.done = true }, () => { hold.done = true })
35
+ chains.set(key, tail)
36
+ void tail.then(() => { if (chains.get(key) === tail) chains.delete(key) })
37
+ return run
38
+ }
39
+
40
+ /** @internal the keys with a chain right now (a test's look at the queue). */
41
+ export const _orderedKeys = (): OrderedKey[] => [...chains.keys()]