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
@@ -19,12 +19,43 @@
19
19
  * for the new one. A socket's frames are served ONE AT A TIME, in the order they came: a `hello`
20
20
  * never overtakes the subscription sent before it, and two subscriptions of a guest do not mint two
21
21
  * sessions.
22
+ *
23
+ * A computed subscription (a `sub` frame with `computed: true`, ./computed.ts) goes the same way, with
24
+ * a number the hub mints for it alone: `subscribe(scope, id, args, ctx, sub)` runs it where the bundle
25
+ * lives and answers its value (the `ok` carries it), the bundle's side keeps it fresh and publishes each
26
+ * new value as an envelope to (`id`, `sub`) — the host's `hub.publish`, as a channel's — and
27
+ * `unsubscribe(scope, sub)` tells it the subscription is over (an unsub, a closed socket, another
28
+ * session). The hub sends a value only when it differs from the last one sent (JSON), and a host whose
29
+ * bundle starts in a new process (a deploy, a cold start) calls `hub.rerun(scope)`: every live computed
30
+ * subscription of it runs there again, in its socket's turn. A first run that fails is a refusal (`err`,
31
+ * the subscription is gone); a later one that fails is an `err` too, and the subscription stands.
32
+ *
33
+ * Presence: a subscription is presence. The hub keeps who is subscribed where — (scope, export id, key)
34
+ * → sessions, the key a computed's arguments or a channel's group as JSON (./channel.ts `presenceKey`) —
35
+ * and answers `presence(scope, id, key)`: what `watchers` / `members` read in the bundle, asked over the
36
+ * host's IPC. A subscription's session is its subscriber's `request.session`, which the bundle's side
37
+ * decides: the envelope of its guard / first run names it (`subscriber`); one without a session is not
38
+ * listed. A session is listed once however many subscriptions it has there (two screens, two sockets).
39
+ * A leave is not at once: a session whose last subscription of a key ended (an unsub, a closed socket,
40
+ * a `hello` with another session) stays listed for PRESENCE_GRACE_MS, and is gone only if none of its
41
+ * subscriptions of that key came back meanwhile — a socket that drops on a Wi-Fi → LTE switch and
42
+ * comes back does not flicker. When the sessions of a key change (a join at once, a leave after its
43
+ * grace), `presenceChanged(scope, id, key)` tells the bundle's side, which runs the computeds that read
44
+ * them again. The table is the hub's, in the host's main process: a new worker loses nothing of it.
45
+ *
46
+ * A session opens at most RATE_LIMITS.subscribe subscriptions (./rateLimit.ts) — a `sub` past it is refused
47
+ * with an `err` of status 429, on every host alike.
22
48
  */
23
49
 
50
+ import { presenceKey } from "./channel"
51
+ import { callerKey, createRateLimiter, RATE_LIMITS, TOO_MANY_CALLS, type RateLimiter } from "./rateLimit"
24
52
  import type { RequestContext } from "./context"
25
53
  import type { InvokeResult } from "./runtime"
26
54
  import type { ChannelGroup, ServerFrame } from "../runtime/wire"
27
55
 
56
+ /** How long a session that left a key stays listed (ms): a socket that comes back within it is no leave. */
57
+ export const PRESENCE_GRACE_MS = 5_000
58
+
28
59
  type Ctx = Omit<RequestContext, "id">
29
60
 
30
61
  /** Where a group's frames go. The default keeps the sockets of a key in a set; a host whose
@@ -38,8 +69,20 @@ export type HubFanout<S> = {
38
69
 
39
70
  export type ChannelHubOptions<S> = {
40
71
  send: (socket: S, frame: string) => void
41
- /** Run the hooks of channel `ch` (createSubscribeGuard, wherever the bundle lives): `result` = the group. */
42
- subscribe: (scope: string, ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
72
+ /** Run the hooks of channel `ch` (createSubscribeGuard, wherever the bundle lives): `result` = the group.
73
+ * With `sub`: start computed `ch` for that subscription (createComputedHost's `start`): `result` = its value. */
74
+ subscribe: (scope: string, ch: string, args: unknown[], ctx: Ctx, sub?: number) => Promise<InvokeResult>
75
+ /** A computed subscription is over: the bundle's side lets it go (createComputedHost's `end`). Without
76
+ * it the hub serves channels only, and a computed subscription is refused. */
77
+ unsubscribe?: (scope: string, sub: number) => void
78
+ /** The sessions subscribed to (`id`, `key`) changed — a join (at once) or a leave (after its grace): the
79
+ * bundle's side runs the computeds that read them again (createComputedHost's `presenceChanged`). */
80
+ presenceChanged?: (scope: string, id: string, key: string) => void
81
+ /** How long a leave waits (default PRESENCE_GRACE_MS; a test's knob). */
82
+ graceMs?: number
83
+ /** How often a session may subscribe (default RATE_LIMITS.subscribe, per scope and session — ./rateLimit.ts;
84
+ * `null`: no limit). A test's knob. */
85
+ subscribeLimiter?: RateLimiter | null
43
86
  fanout?: HubFanout<S>
44
87
  }
45
88
 
@@ -49,11 +92,19 @@ export type HubConnection = {
49
92
  }
50
93
 
51
94
  export type ChannelHub<S> = {
52
- connect(socket: S, info: { scope?: string, ip?: string, session?: string | null }): HubConnection
95
+ /** `filesBase`: what a stored file's url starts with for this socket (RequestContext's). */
96
+ connect(socket: S, info: { scope?: string, ip?: string, session?: string | null, filesBase?: string }): HubConnection
97
+ /** A channel's message to a group — or, for a computed subscription's (`ch`, `sub`), its next envelope. */
53
98
  publish(scope: string, ch: string, group: ChannelGroup | null, message: unknown): void
99
+ /** The bundle of `scope` runs in a new process: its live computed subscriptions run there again. */
100
+ rerun(scope: string): Promise<void>
101
+ /** Who is subscribed to export `id` under `key` (`presenceKey`) in `scope` now: their sessions, each once,
102
+ * in the order they came — one that left counts until its grace is over. */
103
+ presence(scope: string, id: string, key: string): number[]
54
104
  }
55
105
 
56
106
  const keyOf = (scope: string, ch: string, group: ChannelGroup | null) => `${scope}|${ch}|${JSON.stringify(group)}`
107
+ const tableKey = (scope: string, id: string, key: string) => `${scope}|${id}|${key}`
57
108
 
58
109
  const setFanout = <S>(send: (socket: S, frame: string) => void): HubFanout<S> => {
59
110
  const sockets = new Map<string, Set<S>>()
@@ -73,25 +124,143 @@ const setFanout = <S>(send: (socket: S, frame: string) => void): HubFanout<S> =>
73
124
  }
74
125
  }
75
126
 
127
+ /** One socket, as the computed subscriptions on it need it after its frames are served. */
128
+ type Conn<S> = {
129
+ scope: string
130
+ socket: S
131
+ ip?: string
132
+ filesBase?: string
133
+ session: string | null
134
+ closed: boolean
135
+ /** Run `task` in this socket's turn (after the frames before it). */
136
+ enqueue(task: () => Promise<void>): Promise<void>
137
+ }
138
+ type Computed<S> = {
139
+ conn: Conn<S>
140
+ ch: string
141
+ args: unknown[]
142
+ /** the client's id of the subscription */
143
+ id: number
144
+ /** the hub's: its group */
145
+ sub: number
146
+ /** the JSON of the last value sent */
147
+ last: string | undefined
148
+ /** a run of it is on its way (the first, a rerun): what is published meanwhile is newer, and waits */
149
+ starting: boolean
150
+ queued: InvokeResult | null
151
+ /** the session it is present as (its runs say so), once its first run is answered */
152
+ here: number | undefined
153
+ }
154
+ /** A session under one key: how many of its subscriptions are there; with none, the timer of its leave. */
155
+ type Holder = { n: number, leaving: ReturnType<typeof setTimeout> | null }
156
+
157
+ const ctxOf = <S>(conn: Conn<S>): Ctx => ({
158
+ headers: conn.session ? { authorization: `Bearer ${conn.session}` } : {},
159
+ ip: conn.ip,
160
+ sessionToken: conn.session ?? undefined,
161
+ ...(conn.filesBase !== undefined ? { filesBase: conn.filesBase } : {}),
162
+ })
163
+
76
164
  export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> => {
77
165
  const fanout = opts.fanout ?? setFanout(opts.send)
78
166
  const frame = (f: ServerFrame) => JSON.stringify(f)
167
+ /** live computed subscriptions, by the hub's number */
168
+ const computeds = new Map<number, Computed<S>>()
169
+ const conns = new Set<Conn<S>>()
170
+ let seq = 0
171
+ const grace = opts.graceMs ?? PRESENCE_GRACE_MS
172
+ const subscribeLimiter = opts.subscribeLimiter === undefined ? createRateLimiter(RATE_LIMITS.subscribe) : opts.subscribeLimiter
173
+ /** presence: tableKey → session → its holder, in the order the sessions came */
174
+ const table = new Map<string, Map<number, Holder>>()
175
+
176
+ const join = (scope: string, id: string, key: string, session: number) => {
177
+ const k = tableKey(scope, id, key)
178
+ let here = table.get(k)
179
+ if (!here) table.set(k, here = new Map())
180
+ const h = here.get(session)
181
+ if (h) {
182
+ // back within the grace (a new socket, a re-subscription): it never left
183
+ h.n++
184
+ if (h.leaving) { clearTimeout(h.leaving); h.leaving = null }
185
+ return
186
+ }
187
+ here.set(session, { n: 1, leaving: null })
188
+ opts.presenceChanged?.(scope, id, key)
189
+ }
190
+ const leave = (scope: string, id: string, key: string, session: number) => {
191
+ const k = tableKey(scope, id, key)
192
+ const here = table.get(k)
193
+ const h = here?.get(session)
194
+ if (!here || !h || --h.n > 0) return
195
+ h.leaving = setTimeout(() => {
196
+ if (here.get(session) !== h || h.n > 0) return
197
+ here.delete(session)
198
+ if (here.size === 0 && table.get(k) === here) table.delete(k)
199
+ opts.presenceChanged?.(scope, id, key)
200
+ }, grace)
201
+ ;(h.leaving as { unref?: () => void }).unref?.()
202
+ }
203
+ /** a computed subscription is present as `session` from now on (a run said so) */
204
+ const presentAs = (c: Computed<S>, session: number | undefined) => {
205
+ if (session === undefined || session === c.here) return
206
+ const key = presenceKey(c.args)
207
+ join(c.conn.scope, c.ch, key, session)
208
+ if (c.here !== undefined) leave(c.conn.scope, c.ch, key, c.here)
209
+ c.here = session
210
+ }
211
+
212
+ // a token minted by a run (the first contact of a guest): the socket is that session from now on
213
+ const adopt = (conn: Conn<S>, token: string) => {
214
+ conn.session = token
215
+ opts.send(conn.socket, frame({ t: "session", session: token }))
216
+ }
217
+ const deliver = (c: Computed<S>, r: InvokeResult) => {
218
+ if (c.starting) { c.queued = r; return }
219
+ if (!r.ok) { opts.send(c.conn.socket, frame({ t: "err", id: c.id, status: r.status, message: r.message })); return }
220
+ const data = r.result === undefined ? null : r.result
221
+ const json = JSON.stringify(data)
222
+ if (json === c.last) return
223
+ c.last = json
224
+ opts.send(c.conn.socket, frame({ t: "ev", ch: c.ch, group: c.sub, data }))
225
+ }
226
+ /** a run of `c` is back: its answer, then what was published while it ran */
227
+ const settle = (c: Computed<S>, r: InvokeResult) => {
228
+ c.starting = false
229
+ deliver(c, r)
230
+ const q = c.queued
231
+ c.queued = null
232
+ if (q) deliver(c, q)
233
+ }
79
234
 
80
235
  return {
81
236
  connect(socket, info) {
82
237
  const scope = info.scope ?? ""
83
- let session = info.session ?? null
84
- let closed = false
85
238
  let queue: Promise<void> = Promise.resolve()
86
- /** client sub id → key */
87
- const subs = new Map<number, string>()
239
+ const conn: Conn<S> = {
240
+ scope, socket, ip: info.ip, filesBase: info.filesBase, session: info.session ?? null, closed: false,
241
+ enqueue: (task) => (queue = queue.then(() => conn.closed ? undefined : task()).catch(() => {})),
242
+ }
243
+ conns.add(conn)
244
+ /** client sub id → fan-out key, or the hub's number of a computed subscription */
245
+ const subs = new Map<number, string | number>()
88
246
  /** key → how many of this socket's subs are on it (the fan-out hears of the first and the last) */
89
247
  const counts = new Map<string, number>()
248
+ /** client sub id of a channel subscription → where it is present, and as whom */
249
+ const held = new Map<number, { ch: string, key: string, session: number }>()
90
250
 
91
251
  const drop = (id: number) => {
92
252
  const key = subs.get(id)
93
253
  if (key === undefined) return
94
254
  subs.delete(id)
255
+ if (typeof key === "number") {
256
+ const c = computeds.get(key)
257
+ computeds.delete(key)
258
+ if (c?.here !== undefined) leave(scope, c.ch, presenceKey(c.args), c.here)
259
+ opts.unsubscribe?.(scope, key)
260
+ return
261
+ }
262
+ const p = held.get(id)
263
+ if (p) { held.delete(id); leave(scope, p.ch, p.key, p.session) }
95
264
  const n = (counts.get(key) ?? 1) - 1
96
265
  if (n > 0) { counts.set(key, n); return }
97
266
  counts.delete(key)
@@ -99,24 +268,53 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
99
268
  }
100
269
  const dropAll = () => { for (const id of [...subs.keys()]) drop(id) }
101
270
 
271
+ /** A computed subscription: its first run where the bundle lives; the `ok` carries the value. */
272
+ const compute = async (id: number, ch: string, args: unknown[]) => {
273
+ if (!opts.unsubscribe) { opts.send(socket, frame({ t: "err", id, status: 404, message: `${ch}: this server keeps no computed values` })); return }
274
+ const c: Computed<S> = { conn, ch, args, id, sub: ++seq, last: undefined, starting: true, queued: null, here: undefined }
275
+ computeds.set(c.sub, c)
276
+ const r = await opts.subscribe(scope, ch, args, ctxOf(conn), c.sub)
277
+ if (conn.closed) { computeds.delete(c.sub); opts.unsubscribe(scope, c.sub); return }
278
+ if (r.session) adopt(conn, r.session)
279
+ if (!r.ok) {
280
+ // a first run that fails is a refusal: nothing is kept
281
+ computeds.delete(c.sub)
282
+ opts.unsubscribe(scope, c.sub)
283
+ opts.send(socket, frame({ t: "err", id, status: r.status, message: r.message }))
284
+ return
285
+ }
286
+ drop(id)
287
+ const data = r.result === undefined ? null : r.result
288
+ c.last = JSON.stringify(data)
289
+ opts.send(socket, frame({ t: "ok", id, group: c.sub, data }))
290
+ subs.set(id, c.sub)
291
+ presentAs(c, r.subscriber)
292
+ settle(c, { ok: true, result: data })
293
+ }
294
+
102
295
  const serve = async (msg: any) => {
103
296
  switch (msg?.t) {
104
297
  case "hello": {
105
298
  const next = typeof msg.session === "string" && msg.session ? msg.session : null
106
- if (next === session) return
107
- session = next
299
+ if (next === conn.session) return
300
+ conn.session = next
108
301
  dropAll()
109
302
  return
110
303
  }
111
304
  case "sub": {
112
305
  const id = Number(msg.id), ch = String(msg.ch ?? "")
113
306
  if (!Number.isFinite(id) || !ch) return
307
+ // a session subscribing too often is refused, the subscription with it (the client hears an RpcError(429))
308
+ if (subscribeLimiter && !subscribeLimiter.take(`${scope}|${callerKey(conn.session, conn.ip)}`)) {
309
+ opts.send(socket, frame({ t: "err", id, status: TOO_MANY_CALLS.status, message: TOO_MANY_CALLS.message }))
310
+ return
311
+ }
114
312
  const args = Array.isArray(msg.args) ? msg.args : []
115
- const ctx: Ctx = { headers: session ? { authorization: `Bearer ${session}` } : {}, ip: info.ip, sessionToken: session ?? undefined }
116
- const r = await opts.subscribe(scope, ch, args, ctx)
117
- if (closed) return
313
+ if (msg.computed === true) return compute(id, ch, args)
314
+ const r = await opts.subscribe(scope, ch, args, ctxOf(conn))
315
+ if (conn.closed) return
118
316
  // a token minted by the hooks (the first contact of a guest) → the client stores it
119
- if (r.session) { session = r.session; opts.send(socket, frame({ t: "session", session: r.session })) }
317
+ if (r.session) adopt(conn, r.session)
120
318
  if (!r.ok) { opts.send(socket, frame({ t: "err", id, status: r.status, message: r.message })); return }
121
319
  const group = (r.result ?? null) as ChannelGroup | null
122
320
  const key = keyOf(scope, ch, group)
@@ -125,6 +323,11 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
125
323
  subs.set(id, key)
126
324
  counts.set(key, (counts.get(key) ?? 0) + 1)
127
325
  if (counts.get(key) === 1) fanout.subscribe(socket, key)
326
+ if (r.subscriber !== undefined) {
327
+ const p = { ch, key: presenceKey(group), session: r.subscriber }
328
+ held.set(id, p)
329
+ join(scope, ch, p.key, p.session)
330
+ }
128
331
  return
129
332
  }
130
333
  case "unsub":
@@ -137,17 +340,44 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
137
340
  message(raw) {
138
341
  let msg: unknown
139
342
  try { msg = JSON.parse(raw) } catch { return queue }
140
- queue = queue.then(() => closed ? undefined : serve(msg)).catch(() => {})
141
- return queue
343
+ return conn.enqueue(() => serve(msg))
142
344
  },
143
345
  close() {
144
- closed = true
346
+ conn.closed = true
347
+ conns.delete(conn)
145
348
  dropAll()
146
349
  },
147
350
  }
148
351
  },
149
352
  publish(scope, ch, group, message) {
353
+ const c = typeof group === "number" ? computeds.get(group) : undefined
354
+ if (c && c.conn.scope === scope && c.ch === ch) { deliver(c, message as InvokeResult); return }
150
355
  fanout.publish(keyOf(scope, ch, group), frame({ t: "ev", ch, group, data: message === undefined ? null : message }))
151
356
  },
357
+ async rerun(scope) {
358
+ const runs: Promise<void>[] = []
359
+ for (const conn of conns) {
360
+ if (conn.scope !== scope) continue
361
+ runs.push(conn.enqueue(async () => {
362
+ // one after another on a socket, in its turn: a first run still ahead in the queue is done by
363
+ // now (and runs again here — it may have run in the old process), and a token the first one
364
+ // mints is the next ones' session
365
+ for (const c of [...computeds.values()]) {
366
+ if (c.conn !== conn || c.starting) continue
367
+ c.starting = true
368
+ let r: InvokeResult
369
+ try { r = await opts.subscribe(scope, c.ch, c.args, ctxOf(conn), c.sub) } catch (e) { r = { ok: false, status: 500, message: (e as Error)?.message ?? "Internal error" } }
370
+ if (conn.closed || computeds.get(c.sub) !== c) continue
371
+ if (r.session) adopt(conn, r.session)
372
+ if (r.ok) presentAs(c, r.subscriber)
373
+ settle(c, r)
374
+ }
375
+ }))
376
+ }
377
+ await Promise.all(runs)
378
+ },
379
+ presence(scope, id, key) {
380
+ return [...table.get(tableKey(scope, id, key))?.keys() ?? []]
381
+ },
152
382
  }
153
383
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The server's clock under a test: `setServerClock(offsetMs)` moves `Date.now()`, `new Date()` (no
3
+ * arguments), `Date()` and `performance.now()` of this process that many ms ahead of the machine's clock
4
+ * — the backend worker of `lecodes test`, where a case has one clock: a flow's `wait` and `clock` steps
5
+ * move the apps' virtual clocks and this one together (tools/cli/src/dev/backendWorker.ts, set over the
6
+ * dev backend's IPC). The clock keeps running in real time; only its reading is shifted, and the offset
7
+ * only grows (the test runner never moves it back), so the server's time never goes backwards.
8
+ *
9
+ * Host-side, process-wide on purpose: the server bundle is evaluated in this process and reads the
10
+ * global `Date` at call time, so one swap covers the bundle, the SDK's host code (sessions, codes) and
11
+ * every module alike. Installed on the first call; an offset of 0 reads the machine's clock as before.
12
+ * Timers are not touched: a deadline is data, and a `setTimeout` on the server is not a scheduler.
13
+ */
14
+
15
+ let offset = 0
16
+ let installed = false
17
+
18
+ const install = () => {
19
+ if (installed) return
20
+ installed = true
21
+ const g = globalThis as unknown as { Date: DateConstructor, performance?: { now(): number } }
22
+ const Real = g.Date
23
+ // whole ms, as the real one reads: a date field takes an integer
24
+ const now = () => Math.floor(Real.now() + offset)
25
+ // A function, not a class: `Date()` called without `new` answers a string, as the real one does.
26
+ function ShiftedDate(this: unknown, ...args: unknown[]) {
27
+ if (!new.target) return new Real(now()).toString()
28
+ return Reflect.construct(Real, args.length === 0 ? [now()] : args, new.target)
29
+ }
30
+ Object.setPrototypeOf(ShiftedDate, Real) // Date.UTC, Date.parse, … are the real ones
31
+ ShiftedDate.prototype = Real.prototype // `instanceof Date` holds both ways
32
+ Object.defineProperty(ShiftedDate, "now", { value: now, writable: true, configurable: true })
33
+ Object.defineProperty(ShiftedDate, "name", { value: "Date" })
34
+ g.Date = ShiftedDate as unknown as DateConstructor
35
+ const perf = g.performance
36
+ if (perf && typeof perf.now === "function") {
37
+ const realNow = perf.now.bind(perf)
38
+ try { Object.defineProperty(perf, "now", { value: () => realNow() + offset, writable: true, configurable: true }) } catch { /* a frozen performance: Date is enough */ }
39
+ }
40
+ }
41
+
42
+ /** Read the clock this many ms ahead of the machine's from now on (a test's case clock). */
43
+ export const setServerClock = (offsetMs: number): void => {
44
+ if (!Number.isFinite(offsetMs)) throw new Error(`setServerClock: ${offsetMs} is not a number of ms`)
45
+ if (offsetMs !== 0) install()
46
+ offset = offsetMs
47
+ }
48
+
49
+ /** The offset the clock reads with now (ms). */
50
+ export const serverClockOffset = (): number => offset
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `computed(fn)` — a server function the runtime keeps fresh (server global). The app calls it once,
3
+ * like an endpoint, or subscribes to it: the value now, and again whenever the data it read changes.
4
+ *
5
+ * export const roomView = computed(async (code: string) => { // game.server.ts
6
+ * const room = await db.room.where({ code }).first()
7
+ * const players = await db.player.where({ room: code }).select({ name: true, device: true })
8
+ * return { room, players, me: request.session }
9
+ * })
10
+ *
11
+ * const sub = roomView.subscribe(code, (view) => { room.value = view }) // app: now, then on every change
12
+ * sub.close()
13
+ * const view = await roomView(code) // app: one call
14
+ *
15
+ * The app's `computed` (src/core/signals.ts) is the same idea: a value derived from its inputs, made
16
+ * again when they change. The app's inputs are signals; the server's are the database's rows. Hence its
17
+ * two rules: it only READS (a write inside is an error), and its value follows from its data and its
18
+ * arguments alone (`Math.random()` inside = a value that is new at every run).
19
+ *
20
+ * How it stays fresh (./computedHost.ts): a subscription runs `fn` in the subscriber's request scope
21
+ * (`request.session`, `db.auth`), and every read the db client sends meanwhile becomes a WATCH of the
22
+ * database (./db/watch.ts). When the database says a watched result may have changed, `fn` runs again —
23
+ * once for any number of signals during a run — and the subscriber hears the value only when it differs
24
+ * from the last one (JSON equality). The subscription rides the channel socket (a `sub` frame with
25
+ * `computed: true`, ../runtime/wire.ts); the hub (./channelHub.ts) holds it across a new worker (a deploy,
26
+ * an eviction) and runs it there again.
27
+ *
28
+ * Identity, as a channel's: the export (`<path>#<name>`), set by the runtime when the bundle is loaded
29
+ * (`loadServerModules`). In the bundle the export is callable — another server function may
30
+ * `await roomView(code)`, its reads are then that function's own — and `subscribe` only throws; in the
31
+ * app it is the proxy `__computed` of src/runtime/rpc.ts.
32
+ *
33
+ * Who is here: `roomView.watchers(code)` — the sessions subscribed to it with these arguments now (the
34
+ * hub's table, read as a channel's `members` is: ./channel.ts `askPresence`). A read, not a hook: inside
35
+ * a computed it is one more input, and a join or a leave there runs that computed again (./computedHost.ts).
36
+ */
37
+
38
+ import { askPresence, type ChannelSubscription } from "./channel"
39
+
40
+ export type ComputedOptions = {
41
+ /** The server refused the subscription (its first run threw), or a later run failed: an `RpcError`,
42
+ * its `status` the thrown one. Without it the failure is logged. A refused subscription is asked
43
+ * again when the session changes; after a failed later run the last value stands and the next change
44
+ * runs it again. */
45
+ error?: (e: Error & { readonly status: number }) => void
46
+ }
47
+
48
+ /** A server function the runtime keeps fresh: `computed(async (…args) => value)`. */
49
+ export interface ServerComputed<A extends unknown[], R> {
50
+ /** One call, as an endpoint's. */
51
+ (...args: A): Promise<R>
52
+ /** App: `handler` gets the value now, and again each time it changes, until `close()`; the arguments
53
+ * before it are the function's. */
54
+ subscribe(...args: [...args: A, handler: (value: R) => void, options?: ComputedOptions]): ChannelSubscription
55
+ /** Server: the sessions (`request.session`) subscribed to it with these arguments now, each once — the
56
+ * arguments as the app passed them, matched as JSON. Inside a `computed`, a join or a leave runs it again. */
57
+ watchers(...args: A): Promise<number[]>
58
+ }
59
+
60
+ /** @internal what the host reads of a computed (./runtime.ts, ./computedHost.ts) */
61
+ export type ComputedRecord = ((...args: unknown[]) => Promise<unknown>) & {
62
+ /** set by the runtime: `<path>#<export>` */
63
+ __id: string | null
64
+ readonly __computed: true
65
+ readonly __fn: (...args: unknown[]) => Promise<unknown>
66
+ }
67
+
68
+ export const computed = <A extends unknown[], R>(fn: (...args: A) => Promise<R>): ServerComputed<A, R> => {
69
+ if (typeof fn !== "function") throw new Error("computed(fn): fn is the async function that reads the value")
70
+ const c = ((...args: A) => fn(...args)) as unknown as Record<string, unknown>
71
+ c.__id = null
72
+ c.__computed = true
73
+ c.__fn = fn
74
+ c.subscribe = () => { throw new Error("computed.subscribe: the app subscribes — in server code, call it: await view(...args)") }
75
+ c.watchers = async (...args: unknown[]) => {
76
+ // the app's transport leaves out the trailing arguments that are undefined: so does the key
77
+ while (args.length && args[args.length - 1] === undefined) args.pop()
78
+ return askPresence("computed.watchers", c.__id as string | null, args)
79
+ }
80
+ return c as unknown as ServerComputed<A, R>
81
+ }
82
+
83
+ export const isComputed = (v: unknown): v is ComputedRecord =>
84
+ typeof v === "function" && (v as Partial<ComputedRecord>).__computed === true