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.
- package/dist/global.d.ts +11 -7
- package/dist/types/inject.d.ts +2 -1
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
- package/dist/types/runtime/firstCall.d.ts +12 -0
- package/dist/types/runtime/gestures.d.ts +52 -0
- package/dist/types/runtime/rpc.d.ts +24 -0
- package/dist/types/runtime/wire.d.ts +11 -4
- package/dist/types/server/channel.d.ts +16 -0
- package/dist/types/server/computed.d.ts +59 -0
- package/dist/types/server/context.d.ts +13 -1
- package/dist/types/server/db/fields.d.ts +2 -1
- package/dist/types/server/db/marci/query.d.ts +53 -6
- package/dist/types/server/inject.d.ts +2 -0
- package/dist/types/server/ordered.d.ts +18 -0
- package/dist/types/ui/UIModal.d.ts +5 -3
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/src/bridges/tree.d.ts +14 -4
- package/src/compile/serverSplit.ts +8 -4
- package/src/compile/serverTypes.ts +17 -3
- package/src/inject.ts +5 -2
- package/src/plugin.ts +3 -1
- package/src/plugins/gen/ar/sdk/ar.ts +59 -14
- package/src/runtime/firstCall.ts +28 -0
- package/src/runtime/gestures.ts +190 -0
- package/src/runtime/rpc.ts +141 -12
- package/src/runtime/wire.ts +11 -6
- package/src/server/channel.ts +32 -0
- package/src/server/channelHub.ts +246 -16
- package/src/server/clock.ts +50 -0
- package/src/server/computed.ts +84 -0
- package/src/server/computedHost.ts +413 -0
- package/src/server/context.ts +14 -2
- package/src/server/db/defineDb.ts +47 -7
- package/src/server/db/fields.ts +2 -1
- package/src/server/db/httpTransport.ts +37 -2
- package/src/server/db/httpWatch.ts +180 -0
- package/src/server/db/marci/query.ts +112 -7
- package/src/server/db/watch.ts +160 -0
- package/src/server/files/host.ts +2 -1
- package/src/server/host.ts +19 -4
- package/src/server/inject.ts +6 -2
- package/src/server/ordered.ts +41 -0
- package/src/server/rateLimit.ts +72 -0
- package/src/server/runtime.ts +127 -9
- package/src/ui/UIModal.ts +44 -9
- package/src/ui/UINode.ts +4 -0
- package/src/ui/tree.ts +8 -0
- 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
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
246
|
-
return (await
|
|
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
|
|
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
|
|
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)
|
package/src/server/db/fields.ts
CHANGED
|
@@ -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()
|
|
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
|
|
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 ?? {})
|