lecodes-sdk 2.5.1 → 2.8.0

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 (50) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/gl/Foliage.d.ts +6 -0
  3. package/dist/types/inject.d.ts +1 -1
  4. package/dist/types/runtime/firstCall.d.ts +12 -0
  5. package/dist/types/runtime/rpc.d.ts +24 -0
  6. package/dist/types/runtime/wire.d.ts +11 -4
  7. package/dist/types/server/channel.d.ts +16 -0
  8. package/dist/types/server/computed.d.ts +59 -0
  9. package/dist/types/server/context.d.ts +13 -1
  10. package/dist/types/server/db/fields.d.ts +2 -1
  11. package/dist/types/server/db/marci/query.d.ts +53 -6
  12. package/dist/types/server/inject.d.ts +2 -0
  13. package/dist/types/server/ordered.d.ts +18 -0
  14. package/dist/types/ui/UIBottomSheet.d.ts +21 -14
  15. package/dist/types/ui/UIModal.d.ts +50 -22
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +2 -2
  19. package/src/bridges/gl.d.ts +4 -1
  20. package/src/chisel.ts +1 -1
  21. package/src/compile/compileProject.ts +7 -2
  22. package/src/compile/serverSplit.ts +8 -4
  23. package/src/compile/serverTypes.ts +17 -3
  24. package/src/gl/Foliage.ts +11 -1
  25. package/src/inject.ts +3 -2
  26. package/src/runtime/firstCall.ts +28 -0
  27. package/src/runtime/rpc.ts +141 -12
  28. package/src/runtime/wire.ts +11 -6
  29. package/src/server/channel.ts +32 -0
  30. package/src/server/channelHub.ts +246 -16
  31. package/src/server/clock.ts +50 -0
  32. package/src/server/computed.ts +84 -0
  33. package/src/server/computedHost.ts +413 -0
  34. package/src/server/context.ts +14 -2
  35. package/src/server/db/defineDb.ts +47 -7
  36. package/src/server/db/fields.ts +2 -1
  37. package/src/server/db/httpTransport.ts +37 -2
  38. package/src/server/db/httpWatch.ts +180 -0
  39. package/src/server/db/marci/query.ts +112 -7
  40. package/src/server/db/watch.ts +160 -0
  41. package/src/server/files/host.ts +2 -1
  42. package/src/server/host.ts +19 -4
  43. package/src/server/inject.ts +6 -2
  44. package/src/server/ordered.ts +41 -0
  45. package/src/server/rateLimit.ts +72 -0
  46. package/src/server/runtime.ts +127 -9
  47. package/src/ui/UIBottomSheet.ts +39 -30
  48. package/src/ui/UIModal.ts +128 -31
  49. package/src/ui/UIWidget.ts +21 -8
  50. package/src/version.ts +1 -1
@@ -0,0 +1,413 @@
1
+ /**
2
+ * The host side of `computed` (./computed.ts): what a worker does for the computed subscriptions the
3
+ * hub (./channelHub.ts) hands it. Built by `createComputedHost` (./runtime.ts), which gives it the run
4
+ * of one computed in a subscriber's request scope; everything else is here:
5
+ *
6
+ * const computed = createComputedHost(server, { watch, auth, onError })
7
+ * await computed.start(id, args, ctx, sub) // the first run: the envelope's `result` is the value
8
+ * computed.end(sub) // the hub says it is over (null = every one)
9
+ * computed.presenceChanged(id, key) // the hub says who is subscribed to (id, key) changed
10
+ *
11
+ * The gate. The bundle's db client hands every operation to `globalThis.__lecodesDbGate` before it
12
+ * sends it (./db/defineDb.ts). Inside a computed's run (an AsyncLocalStorage scope around the function
13
+ * alone — what the host does before and after is not the computed's) a write is refused
14
+ * (`ComputedWriteError`, a 500 with its message) and each read is recorded: watched (./db/watch.ts)
15
+ * before it goes out, so a commit between the two is either in its answer or signalled. A one-shot call
16
+ * of a computed (an endpoint call) runs under the same gate with nothing recorded.
17
+ *
18
+ * Shared runs. A run whose code did not read the request — `request.*`, `db.auth`: whatever asks the
19
+ * request provider, which tells this file (`requestRead`, the one flag of the run's scope) — depends on
20
+ * the arguments and the data alone, so its value is every subscriber's with those arguments: the
21
+ * subscriptions of one key (export, arguments, and the `filesBase` a stored file's url is made of) hear
22
+ * ONE run — one watch set, one presence-dependency set, one last value — each through its own publish
23
+ * (the hub's per-subscription dedupe holds). A run that read the request is its subscriber's alone, as
24
+ * every run was before. Decided at the end of each run, so a key moves between the two as its data
25
+ * does: a shared run that read the request is dropped and each of its subscriptions runs its own (from
26
+ * its watches: nothing is lost or watched twice); an own run that read nothing joins its key's run — or
27
+ * becomes it. The first run of a subscription is always its own: it is the subscription's answer (its
28
+ * arguments checked, its session resolved or minted, a refusal its own); the sharing starts after it.
29
+ *
30
+ * The watches. One per distinct read (model, action, query) in the worker, shared by every run that made
31
+ * it, dropped with the last one. After each run its watch set is the reads of that run: what the previous
32
+ * run read and this one did not is let go.
33
+ *
34
+ * The runs. A "changed" for a watch runs the runs that hold it again — once for any number of signals
35
+ * that come during a run — and a new value is published through the host's channel publisher
36
+ * (`setChannelPublisher`) as an envelope to (computed id, subscription) for each subscription of the run:
37
+ * `{ ok, result }`, or the failure. A value equal to the last one that subscription had (JSON) is not
38
+ * published; a subscription that joined a run under way hears that run's next one. A subscription stands
39
+ * whatever its runs answer: only `end` lets it go — the hub decides that a failed FIRST run is a refusal.
40
+ *
41
+ * Presence. `watchers(...args)` / `members(group)` (./computed.ts, ./channel.ts) ask the host through the
42
+ * seam `setPresenceProvider` installs: the hub's table, over the host's IPC. Inside a run the read is
43
+ * recorded the way a db read is — before it is asked, so a join between the two runs it again — as a
44
+ * dependency on (export id, key); the hub says when the sessions of a key changed (`presenceChanged`: a
45
+ * join at once, a leave after its grace) and the runs that read it go again, through the same coalesced
46
+ * loop. A subscriber is present in its own value: the hub lists it once its first run is answered, and a
47
+ * read inside a run counts in, besides the hub's answer, every subscription of that key this worker has
48
+ * answered (the hub may not have heard of it yet) and, in a subscription's own first run, its subscriber.
49
+ * So `roomView.watchers(code)` inside `roomView(code)` never says "you are away", and a shared run's value
50
+ * lists every one of its subscribers.
51
+ */
52
+
53
+ import { AsyncLocalStorage } from "node:async_hooks"
54
+ import { presenceKey, type PresenceAsk } from "./channel"
55
+ import type { RequestContext } from "./context"
56
+ import type { MarciOp } from "./db/marci/query"
57
+ import { isReadOp, type WatchOp, type WatchSource } from "./db/watch"
58
+ import type { InvokeResult } from "./runtime"
59
+
60
+ type Ctx = Omit<RequestContext, "id">
61
+
62
+ /** A write inside a computed: the rule broken, said as such (a 500 with this message, and the server log). */
63
+ export class ComputedWriteError extends Error {
64
+ constructor(id: string, op: Pick<MarciOp, "model" | "action">) {
65
+ super(`computed ${id}: ${op.model}.${op.action} is a write — a computed only reads (it runs again whenever its data changes); write in an endpoint`)
66
+ this.name = "ComputedWriteError"
67
+ }
68
+ }
69
+
70
+ type Scope = {
71
+ id: string
72
+ record: ((op: WatchOp) => Promise<void>) | null
73
+ /** a presence read of this run: (export id, key) is one of its inputs from now on */
74
+ heard: ((id: string, key: string) => void) | null
75
+ /** who a presence read of (export id, key) counts in besides the hub's answer: this worker's subscriptions of it */
76
+ present: ((id: string, key: string) => number[]) | null
77
+ /** the run's code read the request: its value is its subscriber's own */
78
+ read: (() => void) | null
79
+ }
80
+ const scope = new AsyncLocalStorage<Scope>()
81
+
82
+ const gate = async (ops: MarciOp[]): Promise<void> => {
83
+ const s = scope.getStore()
84
+ if (!s) return
85
+ for (const op of ops) if (!isReadOp(op)) throw new ComputedWriteError(s.id, op)
86
+ if (s.record) for (const op of ops) await s.record({ model: op.model, action: op.action, query: op.query ?? {} })
87
+ }
88
+
89
+ /** Install the gate the bundle's db client calls (idempotent; every host factory of ./runtime.ts does it). */
90
+ export const installDbGate = () => {
91
+ (globalThis as { __lecodesDbGate?: typeof gate }).__lecodesDbGate = gate
92
+ }
93
+
94
+ /** The request provider's hook (./runtime.ts): the code of the computed run in scope read the request —
95
+ * `request.*`, `db.auth`, whatever reads the context the provider gives. Outside a run, nothing. */
96
+ export const requestRead = () => { scope.getStore()?.read?.() }
97
+
98
+ /** Run `fn` as computed `id` called once: a write inside is refused, nothing is recorded. */
99
+ export const readOnly = <T>(id: string, fn: () => T): T => scope.run({ id, record: null, heard: null, present: null, read: null }, fn)
100
+
101
+ /**
102
+ * Host hook (runner / local backend / test): where `watchers` and `members` get their answer — the hub's
103
+ * table (./channelHub.ts `presence`), over the host's IPC. The seam the bundle reads records the read in
104
+ * the computed run it is in, and counts in the subscriptions of that key this worker has answered.
105
+ */
106
+ export const setPresenceProvider = (ask: PresenceAsk | null) => {
107
+ const seam = globalThis as { __lecodesPresence?: PresenceAsk }
108
+ if (!ask) { delete seam.__lecodesPresence; return }
109
+ seam.__lecodesPresence = async (id, key) => {
110
+ const s = scope.getStore()
111
+ s?.heard?.(id, key)
112
+ const here = await ask(id, key)
113
+ const more = (s?.present?.(id, key) ?? []).filter((n) => !here.includes(n))
114
+ return more.length ? [...here, ...more] : here
115
+ }
116
+ }
117
+
118
+ /** One run of a computed in a subscriber's request scope (./runtime.ts): `around` wraps the function alone;
119
+ * `me` is the subscriber's `request.session` as it stands, minting nothing. */
120
+ export type ComputedRun = (id: string, args: unknown[], ctx: Ctx, around: <T>(fn: () => T, me: () => number | undefined) => T) => Promise<InvokeResult>
121
+ type Publisher = (ch: string, group: number, envelope: InvokeResult) => void
122
+
123
+ export type ComputedHost = {
124
+ /** Subscription `sub` (the hub's number) of computed `id`: the first run, its envelope. From now on
125
+ * the host keeps it fresh, until `end`. */
126
+ start(id: string, args: unknown[], ctx: Ctx, sub: number): Promise<InvokeResult>
127
+ /** The subscription is over (`null` = every one): its watches go. An unknown one is ignored. */
128
+ end(sub: number | null): void
129
+ /** The sessions subscribed to (`id`, `key`) changed (the hub says so): the runs that read them go again. */
130
+ presenceChanged(id: string, key: string): void
131
+ /** Every subscription ended and the source closed (the worker is going away). */
132
+ close(): void
133
+ }
134
+
135
+ type Watch = { key: string, id: number | null, ready: Promise<void>, runs: Set<Run> }
136
+ /** What runs: one subscription's own, or — when it read nothing of the request — the one of every
137
+ * subscription of its key (`shared`). */
138
+ type Run = {
139
+ id: string, args: unknown[]
140
+ /** the key it is the run of (`shareKey`); null = one subscription's own */
141
+ shared: string | null
142
+ members: Set<Sub>
143
+ /** the watch set of the last finished run */
144
+ watched: Map<string, Watch>
145
+ /** the presence keys the last finished run read */
146
+ heard: Set<string>
147
+ /** the last finished run's envelope, as text */
148
+ last: string | undefined
149
+ /** how many runs began: a member that came during one hears the next */
150
+ runs: number
151
+ running: boolean
152
+ dirty: boolean
153
+ ended: boolean
154
+ }
155
+ type Sub = {
156
+ sub: number, id: string, args: unknown[], ctx: Ctx
157
+ /** what its first run said its subscriber is (`request.session`): who it is present as */
158
+ session: number | undefined
159
+ /** the last envelope it was sent (or answered), as text: a value's JSON, a failure's status and message */
160
+ last: string | undefined
161
+ /** the run it hears, from the run after number `since` */
162
+ run: Run
163
+ since: number
164
+ }
165
+
166
+ const opKey = (op: WatchOp) => JSON.stringify([op.model, op.action, op.query])
167
+ const heardKey = (id: string, key: string) => JSON.stringify([id, key])
168
+ /** What makes two subscriptions' runs one, when neither read the request: the export, the arguments — and
169
+ * the base a stored file's url is made of (the request's, as the socket reached the backend). */
170
+ const shareKey = (s: Sub) => JSON.stringify([s.id, s.args, s.ctx.filesBase ?? null])
171
+ let opaque = 0
172
+ const textOf = (r: InvokeResult) => {
173
+ if (!r.ok) return `!${r.status} ${r.message}`
174
+ // a value JSON cannot say (a BigInt, a cycle) equals nothing: it goes on, and fails where it is sent
175
+ try { return JSON.stringify(r.result === undefined ? null : r.result) } catch { return `?${opaque++}` }
176
+ }
177
+ /** What travels to the hub: the envelope without what only a call's response carries. */
178
+ const bare = (r: InvokeResult): InvokeResult => r.ok ? { ok: true, result: r.result === undefined ? null : r.result } : { ok: false, status: r.status, message: r.message }
179
+
180
+ export const computedSubscriptions = (opts: { run: ComputedRun, watch: WatchSource | null, publish?: Publisher }): ComputedHost => {
181
+ const { run, watch: source } = opts
182
+ const publish: Publisher = opts.publish ?? ((ch, group, envelope) => {
183
+ const p = (globalThis as { __lecodesPublish?: (ch: string, group: number, data: unknown) => void }).__lecodesPublish
184
+ if (!p) throw new Error("computed: no host publisher installed")
185
+ p(ch, group, envelope)
186
+ })
187
+ const subs = new Map<number, Sub>()
188
+ /** the shared runs, by their key */
189
+ const groups = new Map<string, Run>()
190
+ const watches = new Map<string, Watch>()
191
+ const byId = new Map<number, Watch>()
192
+ /** presence key → the runs that read it */
193
+ const listeners = new Map<string, Set<Run>>()
194
+ /** presence key (export id, its arguments) → the subscriptions of it this worker has answered */
195
+ const present = new Map<string, Set<Sub>>()
196
+
197
+ const listen = (key: string, r: Run) => {
198
+ let set = listeners.get(key)
199
+ if (!set) listeners.set(key, set = new Set())
200
+ set.add(r)
201
+ }
202
+ const unlisten = (key: string, r: Run) => {
203
+ const set = listeners.get(key)
204
+ if (set?.delete(r) && set.size === 0) listeners.delete(key)
205
+ }
206
+ const presentAt = (s: Sub) => heardKey(s.id, presenceKey(s.args))
207
+ const presentIn = (id: string, key: string, me: number | undefined): number[] => {
208
+ const out = new Set<number>()
209
+ for (const s of present.get(heardKey(id, key)) ?? []) if (s.session !== undefined) out.add(s.session)
210
+ if (me !== undefined) out.add(me)
211
+ return [...out]
212
+ }
213
+
214
+ const release = (w: Watch, r: Run) => {
215
+ if (!w.runs.delete(r) || w.runs.size) return
216
+ if (watches.get(w.key) === w) watches.delete(w.key)
217
+ const drop = () => { byId.delete(w.id!); source?.unwatch(w.id!) }
218
+ // the engine's answer may still be on its way: it goes once it came
219
+ if (w.id !== null) drop()
220
+ else void w.ready.then(drop, () => {})
221
+ }
222
+
223
+ /** A read of run `r` in the run whose watch set is `used`: watched before it is sent. */
224
+ const record = (r: Run, used: Map<string, Watch>) => async (op: WatchOp) => {
225
+ const key = opKey(op)
226
+ let w = used.get(key) ?? watches.get(key)
227
+ if (!w) {
228
+ if (!source) throw new Error(`computed ${r.id}: this host watches no database, so a computed that reads one cannot be kept fresh`)
229
+ const fresh: Watch = { key, id: null, ready: null as unknown as Promise<void>, runs: new Set() }
230
+ fresh.ready = source.watch(op).then(
231
+ (id) => { fresh.id = id; byId.set(id, fresh) },
232
+ (e) => { if (watches.get(key) === fresh) watches.delete(key); throw e },
233
+ )
234
+ watches.set(key, fresh)
235
+ w = fresh
236
+ }
237
+ // in the set from now on: a change while the run goes on runs it again
238
+ w.runs.add(r)
239
+ used.set(key, w)
240
+ await w.ready
241
+ }
242
+
243
+ const personal = (s: Sub): Run => {
244
+ const r: Run = { id: s.id, args: s.args, shared: null, members: new Set([s]), watched: new Map(), heard: new Set(), last: s.last, runs: 0, running: false, dirty: false, ended: false }
245
+ s.run = r
246
+ s.since = 0
247
+ return r
248
+ }
249
+
250
+ /** A run is over for good: its watches and its presence keys go (a run still going lets go of its own reads when it is back). */
251
+ const drop = (r: Run) => {
252
+ r.ended = true
253
+ if (r.shared !== null && groups.get(r.shared) === r) groups.delete(r.shared)
254
+ for (const w of r.watched.values()) release(w, r)
255
+ r.watched = new Map()
256
+ for (const k of r.heard) unlisten(k, r)
257
+ r.heard = new Set()
258
+ }
259
+
260
+ const runOnce = async (r: Run): Promise<{ result: InvokeResult, personal: boolean, n: number }> => {
261
+ // whose request scope it runs in: a shared one's code reads nothing of it
262
+ const by = r.members.values().next().value as Sub
263
+ const n = ++r.runs
264
+ const used = new Map<string, Watch>()
265
+ const heard = new Set<string>()
266
+ let read = false
267
+ const own = presenceKey(r.args)
268
+ const result = await run(r.id, r.args, by.ctx, (fn, me) => scope.run({
269
+ id: r.id,
270
+ record: record(r, used),
271
+ // in the set from now on, as a read: a join while the run goes on runs it again
272
+ heard: (id, key) => { const k = heardKey(id, key); heard.add(k); listen(k, r) },
273
+ // a subscription is present in its own value — before the hub has heard its answer, and as whoever
274
+ // its own run makes it (a session minted meanwhile)
275
+ present: (id, key) => presentIn(id, key, r.shared === null && id === r.id && key === own ? me() : undefined),
276
+ read: () => { read = true },
277
+ }, fn))
278
+ // a token minted by this run (a device's first contact): the next runs are that device's
279
+ if (result.session) by.ctx = { ...by.ctx, sessionToken: result.session, headers: { ...by.ctx.headers, authorization: `Bearer ${result.session}` } }
280
+ for (const [key, w] of r.watched) if (!used.has(key)) release(w, r)
281
+ r.watched = used
282
+ for (const k of r.heard) if (!heard.has(k)) unlisten(k, r)
283
+ r.heard = heard
284
+ if (r.ended) {
285
+ for (const w of used.values()) release(w, r)
286
+ for (const k of heard) unlisten(k, r)
287
+ }
288
+ return { result, personal: read, n }
289
+ }
290
+
291
+ /** Run `r` read nothing of the request: it is its key's run from now on — or the key has one, which takes its subscription. */
292
+ const share = (r: Run) => {
293
+ const [s] = r.members
294
+ const key = shareKey(s)
295
+ const g = groups.get(key)
296
+ if (!g) { r.shared = key; groups.set(key, r); return }
297
+ g.members.add(s)
298
+ s.run = g
299
+ s.since = g.runs
300
+ drop(r)
301
+ // its value may be newer or older than the key's, or the key's run under way began before it came: one more run says
302
+ if (r.dirty || g.running || s.last !== g.last) schedule(g)
303
+ }
304
+
305
+ /** The shared run `g` read the request: each of its subscriptions runs its own from now on, starting from its
306
+ * watches and presence keys (a change before the first of them is not lost, nothing is watched twice). */
307
+ const split = (g: Run) => {
308
+ const members = [...g.members]
309
+ g.members.clear()
310
+ for (const s of members) {
311
+ const p = personal(s)
312
+ for (const [k, w] of g.watched) { w.runs.add(p); p.watched.set(k, w) }
313
+ for (const k of g.heard) { listen(k, p); p.heard.add(k) }
314
+ schedule(p)
315
+ }
316
+ drop(g)
317
+ }
318
+
319
+ const loop = async (r: Run) => {
320
+ try {
321
+ while (r.dirty && !r.ended) {
322
+ r.dirty = false
323
+ const { result, personal: mine, n } = await runOnce(r)
324
+ if (r.ended) return
325
+ // the value is one subscriber's: its run says nothing to the others
326
+ if (r.shared !== null && mine) { split(r); return }
327
+ const text = textOf(result)
328
+ r.last = text
329
+ for (const s of r.members) {
330
+ if (s.since >= n || s.last === text) continue
331
+ s.last = text
332
+ publish(s.id, s.sub, bare(result))
333
+ }
334
+ if (r.shared === null && !mine) share(r)
335
+ }
336
+ } finally {
337
+ r.running = false
338
+ }
339
+ }
340
+
341
+ const schedule = (r: Run) => {
342
+ if (r.ended) return
343
+ r.dirty = true
344
+ if (r.running) return
345
+ r.running = true
346
+ void loop(r)
347
+ }
348
+
349
+ const end = (sub: number) => {
350
+ const s = subs.get(sub)
351
+ if (!s) return
352
+ subs.delete(sub)
353
+ const at = presentAt(s)
354
+ const set = present.get(at)
355
+ if (set?.delete(s) && set.size === 0) present.delete(at)
356
+ const r = s.run
357
+ r.members.delete(s)
358
+ if (r.members.size === 0) drop(r)
359
+ }
360
+
361
+ source?.onChanged((ids) => {
362
+ const hit = new Set<Run>()
363
+ for (const id of ids) for (const r of byId.get(id)?.runs ?? []) hit.add(r)
364
+ for (const r of hit) schedule(r)
365
+ })
366
+
367
+ return {
368
+ async start(id, args, ctx, sub) {
369
+ end(sub)
370
+ const s = { sub, id, args, ctx, session: undefined, last: undefined, since: 0 } as Sub
371
+ const r = personal(s)
372
+ r.running = true
373
+ subs.set(sub, s)
374
+ let out: Awaited<ReturnType<typeof runOnce>>
375
+ try {
376
+ out = await runOnce(r)
377
+ } catch (e) {
378
+ r.running = false
379
+ throw e
380
+ }
381
+ const { result, personal: mine } = out
382
+ s.last = r.last = textOf(result)
383
+ if (result.ok && subs.get(sub) === s) {
384
+ s.session = result.subscriber
385
+ const at = presentAt(s)
386
+ let set = present.get(at)
387
+ if (!set) present.set(at, set = new Set())
388
+ set.add(s)
389
+ }
390
+ // what comes after the first run goes once its answer is on its way (a timer, not a microtask — the
391
+ // host posts the answer first, the hub hears the two in order): a run that read nothing of the request
392
+ // joins its key's, and a change during it runs it again
393
+ setTimeout(() => {
394
+ r.running = false
395
+ if (r.ended) return
396
+ if (result.ok && !mine) share(r)
397
+ if (!r.ended && r.dirty) { r.dirty = false; schedule(r) }
398
+ }, 0)
399
+ return result
400
+ },
401
+ end(sub) {
402
+ if (sub === null) for (const key of [...subs.keys()]) end(key)
403
+ else end(sub)
404
+ },
405
+ presenceChanged(id, key) {
406
+ for (const r of [...listeners.get(heardKey(id, key)) ?? []]) schedule(r)
407
+ },
408
+ close() {
409
+ for (const key of [...subs.keys()]) end(key)
410
+ source?.close()
411
+ },
412
+ }
413
+ }
@@ -4,6 +4,10 @@
4
4
  * server bundle carries its own copy of this module (chisel bundles the SDK in), so the host can't
5
5
  * hand it a value by import. `request` reads through that seam; outside a request (module load,
6
6
  * timers after the response) every member is undefined.
7
+ *
8
+ * `session` is decided in ONE place, the host runtime (./runtime.ts `defineSession`), for endpoints
9
+ * and channel hooks alike: every host (the runner, `lecodes dev`, a test) gets it by running calls
10
+ * through `createInvoker` / `createSubscribeGuard`.
7
11
  */
8
12
 
9
13
  export type RequestContext = {
@@ -15,13 +19,18 @@ export type RequestContext = {
15
19
  sessionToken?: string
16
20
  /** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
17
21
  auth?: unknown
22
+ /** The device of the request — `request.session`. Defined by the host runtime (./runtime.ts), read lazily:
23
+ * a host never sets it. */
24
+ readonly session?: number
18
25
  /** The files a multipart call carried, as the receiving process left them (./files/host.ts `Upload`). */
19
26
  uploads?: unknown[]
20
27
  /** What `/files/<id>/<name>` is appended to in a stored file's url: the backend's own base, as this request reached it. */
21
28
  filesBase?: string
22
29
  }
23
30
 
24
- type Provider = () => RequestContext | undefined
31
+ /** `quiet`: the host's own read, not the code's (a stored file's url is made of the request's `filesBase`).
32
+ * Every other read is the code's: inside a computed it makes the run its subscriber's alone (./computedHost.ts). */
33
+ type Provider = (quiet?: boolean) => RequestContext | undefined
25
34
  const seam = globalThis as unknown as { __lecodesRequest?: Provider }
26
35
 
27
36
  /** Host hook (runner / test harness): install the current-request provider. */
@@ -30,11 +39,14 @@ export const setRequestProvider = (provider: Provider | null) => {
30
39
  else delete seam.__lecodesRequest
31
40
  }
32
41
  /** @internal */
33
- export const currentRequest = (): RequestContext | undefined => seam.__lecodesRequest?.()
42
+ export const currentRequest = (quiet?: boolean): RequestContext | undefined => seam.__lecodesRequest?.(quiet)
34
43
 
35
44
  /** The current request (server global). Undefined members outside a request. */
36
45
  export const request = {
37
46
  get id(): string | undefined { return currentRequest()?.id },
38
47
  get headers(): Record<string, string> { return currentRequest()?.headers ?? {} },
39
48
  get ip(): string | undefined { return currentRequest()?.ip },
49
+ /** The calling device: a number that stays the same for as long as the app keeps its storage, without
50
+ * sign-in; with `withAuth` it is `db.auth.sessionId`. Not a secret — the token behind it is. */
51
+ get session(): number | undefined { return currentRequest()?.session },
40
52
  }
@@ -13,7 +13,7 @@ import { createAuthApi } from "../auth/api"
13
13
  import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityFields, sessionFields } from "../auth/models"
14
14
  import { createFileLayer } from "../files/db"
15
15
  import { FILE_MODEL, fileFields, fileOwnerKey, type FileField } from "../files/models"
16
- import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
16
+ import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta, type WatchRegistration } from "./marci/query"
17
17
  import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
18
18
 
19
19
  export const model = <F extends Fields>(fields: F): Model<F> => ({ fields, __model: true })
@@ -22,7 +22,7 @@ export const model = <F extends Fields>(fields: F): Model<F> => ({ fields, __mod
22
22
 
23
23
  // A GLOBAL seam (not a module variable): the compiled server bundle carries its own copy of this
24
24
  // module, so the host's `setDbTransport` and the bundle's `defineDb` only meet through globalThis.
25
- const seam = globalThis as unknown as { __lecodesDbTransport?: MarciTransport, __lecodesDbs?: Db<any>[] }
25
+ const seam = globalThis as unknown as { __lecodesDbTransport?: MarciTransport, __lecodesDbs?: Db<any>[], __lecodesDbGate?: (ops: MarciOp[]) => Promise<void> }
26
26
  /** Host hook: the runner/dev-server installs the project's transport before loading user code. */
27
27
  export const setDbTransport = (transport: MarciTransport | null) => {
28
28
  if (transport) seam.__lecodesDbTransport = transport
@@ -60,6 +60,17 @@ type ModelMeta = {
60
60
 
61
61
  const isRel = (d: FieldDef) => d.kind === "one" || d.kind === "many" || d.kind === "list"
62
62
 
63
+ /** A default the engine would refuse at `$sync` (the server's start) is refused here, where the model is
64
+ * written: a date's is `"now"` or whole milliseconds since 1970; a json's is a JSON value. */
65
+ const checkDefault = (model: string, key: string, d: FieldDef) => {
66
+ if (!d.hasDefault) return
67
+ const v = d.default
68
+ if (d.scalar === "date" && v !== "now" && !(typeof v === "number" && Number.isInteger(v)))
69
+ throw new Error(`defineDb: ${model}.${key}: a date's default is "now" or a number of milliseconds since 1970, not ${JSON.stringify(v)}`)
70
+ if (d.kind === "json" && (v == null || JSON.stringify(v) === undefined))
71
+ throw new Error(`defineDb: ${model}.${key}: a json default must be a JSON value (an object, a list, a string, a number, a boolean) — for "no value" make the field .optional()`)
72
+ }
73
+
63
74
  const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> => {
64
75
  const metas: Record<string, ModelMeta> = {}
65
76
  for (const [name, m] of Object.entries(models)) {
@@ -69,6 +80,7 @@ const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> =>
69
80
  const def = (f as Field<any, any>).def
70
81
  if (def.kind === "struct" && Object.values(def.fields!).some(inner => (inner as Field<any, any>).def.kind === "file"))
71
82
  throw new Error(`defineDb: ${name}.${key} — a file belongs to a model's row: put the t.file() field on ${name} itself, not inside a struct`)
83
+ checkDefault(name, key, def)
72
84
  fields[key] = { ...def }
73
85
  }
74
86
  if (fields.id && !fields.id.isId) fields.id.isId = true
@@ -111,6 +123,7 @@ const ON_DELETE = { cascade: "Cascade", setNull: "SetNull", restrict: "Restrict"
111
123
 
112
124
  const literal = (v: unknown, d: FieldDef): string => {
113
125
  if (d.scalar === "date" && v === "now") return "now()"
126
+ if (d.kind === "json") return JSON.stringify(v) // `Json @default(<JSON literal>)`, compact
114
127
  if (Array.isArray(v)) return `[${v.map(x => literal(x, { ...d, array: false })).join(", ")}]`
115
128
  if (typeof v === "string") return d.kind === "enum" ? v : JSON.stringify(v)
116
129
  return String(v)
@@ -236,21 +249,33 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
236
249
  if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
237
250
  return t
238
251
  }
252
+ // The host looks at what goes out before it does (a GLOBAL seam, as the transport's): inside a
253
+ // `computed` a write is refused and every read is recorded (../computedHost.ts). No host, no look.
254
+ const send = async (op: MarciOp): Promise<any> => {
255
+ const gate = seam.__lecodesDbGate
256
+ if (gate) await gate([op])
257
+ return transport().exec(op)
258
+ }
259
+ const sendBatch = async (ops: MarciOp[]): Promise<any[]> => {
260
+ const gate = seam.__lecodesDbGate
261
+ if (gate) await gate(ops)
262
+ return transport().batch(ops)
263
+ }
239
264
  // A db with file fields runs every operation through the file layer: a write of a file field is the
240
265
  // row's operation and the ones on File after it, in one transaction.
241
266
  let files: ReturnType<typeof createFileLayer> | null = null
242
267
  const exec = async (op: MarciOp): Promise<any> => {
243
- if (!files) return transport().exec(op)
268
+ if (!files) return send(op)
244
269
  const plan = await files.plan(op, 0)
245
- if (!plan.after.length) return files.result(plan.op, await transport().exec(plan.op))
246
- return (await transport().batch([plan.op, ...plan.after]))[0]
270
+ if (!plan.after.length) return files.result(plan.op, await send(plan.op))
271
+ return (await sendBatch([plan.op, ...plan.after]))[0]
247
272
  }
248
273
  const batch = async (ops: MarciOp[]): Promise<any[]> => {
249
- if (!files) return transport().batch(ops)
274
+ if (!files) return sendBatch(ops)
250
275
  // the project's operations keep their places (its `ref`s count them); what follows them goes after
251
276
  const plans: Awaited<ReturnType<typeof files.plan>>[] = []
252
277
  for (let index = 0; index < ops.length; index++) plans.push(await files.plan(ops[index], index))
253
- const results = await transport().batch([...plans.map(p => p.op), ...plans.flatMap(p => p.after)])
278
+ const results = await sendBatch([...plans.map(p => p.op), ...plans.flatMap(p => p.after)])
254
279
  return plans.map((p, index) => files!.result(p.op, results[index]))
255
280
  }
256
281
  const seal = () => {
@@ -292,6 +317,21 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
292
317
  prepareInsert: (name, data) => metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data,
293
318
  // an upsert that creates the row needs the id a uuid model leaves to the client; the key wins when it is the id
294
319
  prepareUpsert: (name, onInsert) => metas[name].uuidId ? { id: crypto.randomUUID(), ...(onInsert ?? {}) } : onInsert,
320
+ // `op.watch`: the transport's watches, of the op as the database sees it (a file field is its relation);
321
+ // the reads again go through `exec`, as any read
322
+ watch: (op, listener) => {
323
+ const t = transport()
324
+ if (!t.watch) throw new Error("db: this host's database cannot watch — op.watch needs a transport with watch()")
325
+ if (!files) return t.watch(op, listener)
326
+ let reg: WatchRegistration | null = null
327
+ let closed = false
328
+ const ready = files.plan(op, 0).then((plan) => {
329
+ if (closed) return
330
+ reg = t.watch!(plan.op, listener)
331
+ return reg.ready
332
+ })
333
+ return { ready, close() { closed = true; reg?.close() } }
334
+ },
295
335
  })
296
336
  const collections: Record<string, unknown> = {}
297
337
  for (const name of Object.keys(own)) collections[name] = collection(name)
@@ -71,7 +71,8 @@ export interface Field<T, M extends FieldMeta = FieldMeta> {
71
71
  unique(group?: string): Field<T, M>
72
72
  /** `@index` — used by `$where`, `$order`, aggregations. */
73
73
  index(): Field<T, M>
74
- /** `@default(...)`; `"now"` on `t.date()` → `now()`. Makes the field optional on insert. */
74
+ /** `@default(...)`; `"now"` on `t.date()` → `now()`, a number is milliseconds since 1970; a json's is
75
+ * any JSON value (`t.json<number[]>().default([])`). Makes the field optional on insert. */
75
76
  default(value: DefaultOf<T, M>): Field<T, SetMeta<M, "hasDefault", true>>
76
77
  /** Primitive list (`String[]`). */
77
78
  array(): Field<T, SetMeta<M, "array", true>>
@@ -6,10 +6,14 @@
6
6
  *
7
7
  * `baseUrl` = `http://host:port/<db>`; an optional bearer token (marcidb-server `MARCI_TOKEN`) is sent
8
8
  * on every request. Pass `fetch` explicitly when the global one might be monkey-patched by untrusted
9
- * code running in the same realm (the runner captures it before evaluating a project bundle).
9
+ * code running in the same realm (the runner captures it before evaluating a project bundle) — and
10
+ * `WebSocket` likewise for `op.watch`, which rides the database's watch socket (./httpWatch.ts, opened
11
+ * with the first watch and in a context of its own, not the request's that asked for it).
10
12
  */
11
13
 
12
- import type { MarciOp, MarciTransport } from "./marci/query"
14
+ import { marciHttpWatchSource, type WatchSocketCtor } from "./httpWatch"
15
+ import type { MarciOp, MarciTransport, WatchListener } from "./marci/query"
16
+ import { outsideRequests, type WatchSource } from "./watch"
13
17
 
14
18
  /** A failed marcidb request: `status` is the HTTP status (0 = network failure), `kind` the server's class of the
15
19
  * failure — a constraint (`unique`, `foreign_key`, `restrict`, `duplicate_id`), `not_found`, `bad_request`,
@@ -39,6 +43,8 @@ const failure = (status: number, text: string): MarciHttpError => {
39
43
  export type MarciHttpOptions = {
40
44
  token?: string | null
41
45
  fetch?: typeof fetch
46
+ /** The watch socket's constructor (`op.watch`); default the global one. */
47
+ WebSocket?: WatchSocketCtor
42
48
  }
43
49
 
44
50
  /** `update/:id` and `delete/:id` take the key in the path: a bare value, or `k=v&k2=v2` for composite keys
@@ -85,7 +91,36 @@ export const marciRequest = async (
85
91
  export const marciHttpTransport = (baseUrl: string, options: MarciHttpOptions = {}): MarciTransport => {
86
92
  const url = baseUrl.replace(/\/+$/, "")
87
93
  const call = (path: string, body?: unknown, method?: string) => marciRequest(`${url}/${path}`, { body, method, token: options.token, fetch: options.fetch })
94
+ // `op.watch`: the signals of the database's watch socket, made with the first watch
95
+ let source: WatchSource | null = null
96
+ const listeners = new Map<number, WatchListener>()
97
+ const watches = () => {
98
+ if (source) return source
99
+ source = marciHttpWatchSource(url, { token: options.token, WebSocket: options.WebSocket })
100
+ source.onChanged((ids) => { for (const id of ids) listeners.get(id)?.changed() })
101
+ return source
102
+ }
88
103
  return {
104
+ watch(op, listener) {
105
+ const src = watches()
106
+ let id: number | null = null
107
+ let closed = false
108
+ const ready = outsideRequests(() => src.watch({ model: op.model, action: op.action, query: op.query ?? {} })).then((n) => {
109
+ if (closed) { src.unwatch(n); return }
110
+ id = n
111
+ listeners.set(n, listener)
112
+ })
113
+ return {
114
+ ready,
115
+ close() {
116
+ if (closed) return
117
+ closed = true
118
+ if (id === null) return
119
+ listeners.delete(id)
120
+ src.unwatch(id)
121
+ },
122
+ }
123
+ },
89
124
  exec(op: MarciOp) {
90
125
  switch (op.action) {
91
126
  case "findMany": return call(`${op.model}/findMany`, op.query ?? {})