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