lecodes-sdk 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/inject.d.ts +1 -1
  3. package/dist/types/runtime/firstCall.d.ts +12 -0
  4. package/dist/types/runtime/rpc.d.ts +24 -0
  5. package/dist/types/runtime/wire.d.ts +11 -4
  6. package/dist/types/server/channel.d.ts +16 -0
  7. package/dist/types/server/computed.d.ts +59 -0
  8. package/dist/types/server/context.d.ts +13 -1
  9. package/dist/types/server/db/fields.d.ts +2 -1
  10. package/dist/types/server/db/marci/query.d.ts +53 -6
  11. package/dist/types/server/inject.d.ts +2 -0
  12. package/dist/types/server/ordered.d.ts +18 -0
  13. package/dist/types/version.d.ts +1 -1
  14. package/dist/types.json +1 -1
  15. package/package.json +2 -2
  16. package/src/compile/serverSplit.ts +8 -4
  17. package/src/compile/serverTypes.ts +17 -3
  18. package/src/inject.ts +3 -2
  19. package/src/runtime/firstCall.ts +28 -0
  20. package/src/runtime/rpc.ts +141 -12
  21. package/src/runtime/wire.ts +11 -6
  22. package/src/server/channel.ts +32 -0
  23. package/src/server/channelHub.ts +246 -16
  24. package/src/server/clock.ts +50 -0
  25. package/src/server/computed.ts +84 -0
  26. package/src/server/computedHost.ts +413 -0
  27. package/src/server/context.ts +14 -2
  28. package/src/server/db/defineDb.ts +47 -7
  29. package/src/server/db/fields.ts +2 -1
  30. package/src/server/db/httpTransport.ts +37 -2
  31. package/src/server/db/httpWatch.ts +180 -0
  32. package/src/server/db/marci/query.ts +112 -7
  33. package/src/server/db/watch.ts +160 -0
  34. package/src/server/files/host.ts +2 -1
  35. package/src/server/host.ts +19 -4
  36. package/src/server/inject.ts +6 -2
  37. package/src/server/ordered.ts +41 -0
  38. package/src/server/rateLimit.ts +72 -0
  39. package/src/server/runtime.ts +127 -9
  40. package/src/version.ts +1 -1
package/dist/global.d.ts CHANGED
@@ -127,7 +127,7 @@ declare global {
127
127
  const SpriteAnimation: typeof SDK.SpriteAnimation
128
128
  const SpriteSheet: typeof SDK.SpriteSheet
129
129
  const StateMachine: typeof SDK.StateMachine
130
- const SvgSource: typeof SDK.SvgSource
130
+ const SvgSource: typeof SDK.SvgSource & typeof SERVER.SvgSource
131
131
  const System: typeof SDK.System
132
132
  const Terrain: typeof SDK.Terrain
133
133
  const Text2D: typeof SDK.Text2D
@@ -181,6 +181,7 @@ declare global {
181
181
  const __channel: typeof SDK.__channel
182
182
  const __compOp: typeof SDK.__compOp
183
183
  const __compWrite: typeof SDK.__compWrite
184
+ const __computed: typeof SDK.__computed
184
185
  const __localAsset: typeof SDK.__localAsset
185
186
  const __pushChannel: typeof SDK.__pushChannel
186
187
  const __pushEnable: typeof SDK.__pushEnable
@@ -195,7 +196,7 @@ declare global {
195
196
  const cells: typeof SDK.cells
196
197
  const clipboard: typeof SDK.clipboard
197
198
  const colorCurve: typeof SDK.colorCurve
198
- const computed: typeof SDK.computed
199
+ const computed: typeof SERVER.computed & typeof SDK.computed
199
200
  const cubicBezier: typeof SDK.cubicBezier
200
201
  const curve: typeof SDK.curve
201
202
  const dataOf: typeof SDK.dataOf
@@ -213,7 +214,7 @@ declare global {
213
214
  const easeOut: typeof SDK.easeOut
214
215
  const effect: typeof SDK.effect
215
216
  const encodeCells: typeof SDK.encodeCells
216
- const env: typeof SDK.env
217
+ const env: typeof SDK.env & typeof SERVER.env
217
218
  const failureOf: typeof SDK.failureOf
218
219
  const fetch: typeof SDK.fetch
219
220
  const fetchLocal: typeof SDK.fetchLocal
@@ -223,13 +224,14 @@ declare global {
223
224
  const localStorage: typeof SDK.localStorage
224
225
  const make: typeof SDK.make
225
226
  const mediaDevices: typeof SDK.mediaDevices
227
+ const online: typeof SDK.online
226
228
  const openFilePicker: typeof SDK.openFilePicker
227
229
  const openURL: typeof SDK.openURL
228
230
  const packTile: typeof SDK.packTile
229
231
  const permissions: typeof SDK.permissions
230
232
  const rawCell: typeof SDK.rawCell
231
233
  const rawIndex: typeof SDK.rawIndex
232
- const ref: typeof SDK.ref
234
+ const ref: typeof SDK.ref & typeof SERVER.ref
233
235
  const registerEditorTool: typeof SDK.registerEditorTool
234
236
  const registerEditorWindow: typeof SDK.registerEditorWindow
235
237
  const registerFont: typeof SDK.registerFont
@@ -724,13 +726,11 @@ declare global {
724
726
  type Wrap2D = SDK.Wrap2D
725
727
  // ---- server globals (available in *.server.ts; using them in client code is a build error) ----
726
728
  const ApiError: typeof SERVER.ApiError
727
- const SvgSource: typeof SERVER.SvgSource
728
729
  const channel: typeof SERVER.channel
729
730
  const defineDb: typeof SERVER.defineDb
730
- const env: typeof SERVER.env
731
731
  const model: typeof SERVER.model
732
+ const ordered: typeof SERVER.ordered
732
733
  const pushChannel: typeof SERVER.pushChannel
733
- const ref: typeof SERVER.ref
734
734
  const request: typeof SERVER.request
735
735
  const t: typeof SERVER.t
736
736
  type ApiError = SERVER.ApiError
@@ -23,7 +23,7 @@ export { device, type HapticStyle, type MotionOptions } from "./runtime/device";
23
23
  export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input";
24
24
  export { WebSocket } from "./runtime/net";
25
25
  export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net";
26
- export { __rpc, __channel, __pushChannel, __serverOnly, RpcError } from "./runtime/rpc";
26
+ export { __rpc, __channel, __computed, __pushChannel, __serverOnly, RpcError, online } from "./runtime/rpc";
27
27
  export { __registerFont } from "./ui/fonts";
28
28
  export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource";
29
29
  export type { ChannelSubscription } from "./server/channel";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The client transport's first-call rule (./rpc.ts), kept apart so a host that calls a backend as a
3
+ * device — a flow's `call` step, `lecodes call` (tools/cli/src/dev/backendCall.ts) — follows the very
4
+ * same rule: a device with no token yet sends its first call alone and the calls behind it wait, so a
5
+ * backend that mints a token on first contact (`request.session`, a guest session) mints ONE — two
6
+ * calls in flight would each get their own and the device would end up with whichever landed last.
7
+ * Once per device per run: the gate opens for good when the first call is back, whether or not it
8
+ * brought a token. Pure: no bridges, no globals.
9
+ */
10
+ export type FirstCallGate = <T>(hasToken: () => boolean, send: () => Promise<T>) => Promise<T>;
11
+ /** One gate per device (and backend): `gate(hasToken, send)` runs `send` under the rule. */
12
+ export declare const firstCallGate: () => FirstCallGate;
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * export const listNotes = __rpc("<serverUrl>", "notes.server.ts#listNotes")
7
7
  * export const chat = __channel("<serverUrl>", "chat.server.ts#chat")
8
+ * export const roomView = __computed("<serverUrl>", "game.server.ts#roomView")
8
9
  * export const db = __serverOnly("schema.server.ts#db")
9
10
  *
10
11
  * Wire (implemented by the runner):
@@ -18,7 +19,11 @@
18
19
  * The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
19
20
  * localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
20
21
  * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
22
+ *
23
+ * `online` (the app's global) is what this transport last learned of its backends: both the calls and
24
+ * the socket go through here, so it is kept here (docs/plans/multiplayer-plan.md D5a).
21
25
  */
26
+ import { type Computed } from "../core/signals";
22
27
  import type { ChannelSubscription } from "../server/channel";
23
28
  /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
24
29
  * (0 = the request itself failed: no network / no server). */
@@ -26,6 +31,14 @@ export declare class RpcError extends Error {
26
31
  readonly status: number;
27
32
  constructor(status: number, message: string);
28
33
  }
34
+ /**
35
+ * Does the app reach its backend: `true` until a call fails without reaching it (`RpcError` status 0)
36
+ * or the channel socket drops and starts reconnecting; `true` again once the socket opens, a message
37
+ * comes over it, or a call is answered — with an error too. With several backends, all of them. Read
38
+ * `.value` in a binding, a `computed` or an `effect`; it is not the device's network (`device.online`)
39
+ * and says nothing of whether what a screen shows is old: a computed re-delivers by itself.
40
+ */
41
+ export declare const online: Computed<boolean>;
29
42
  /** Build the client proxy of endpoint `id`. Positional args travel as a JSON array. */
30
43
  export declare const __rpc: (serverUrl: string, id: string) => (...args: unknown[]) => Promise<any>;
31
44
  /** A server-only export (db, models, helpers) referenced from client code: any use throws. */
@@ -38,6 +51,17 @@ export declare const __serverOnly: (id: string) => any;
38
51
  export declare const __channel: (serverUrl: string, id: string) => {
39
52
  subscribe(...all: unknown[]): ChannelSubscription;
40
53
  publish(): never;
54
+ members(): never;
55
+ };
56
+ /**
57
+ * Build the client proxy of computed `id` — what a `computed()` export of a `*.server.ts` file is in the
58
+ * app (its type is the server's: src/server/computed.ts). Called, it is an endpoint call;
59
+ * `subscribe([...args,] handler, { error? })` hears its value now (the `ok`) and on every change (an `ev`
60
+ * to the subscription's own group), over the channel socket.
61
+ */
62
+ export declare const __computed: (serverUrl: string, id: string) => ((...args: unknown[]) => Promise<any>) & {
63
+ subscribe(...all: unknown[]): ChannelSubscription;
64
+ watchers(): never;
41
65
  };
42
66
  /**
43
67
  * Build the client proxy of push channel `id` — what a `pushChannel()` export of a `*.server.ts` file is
@@ -7,15 +7,20 @@
7
7
  * then DROPS the socket's subscriptions (they were granted to someone else) and the
8
8
  * client sends them again, under new ids.
9
9
  * → sub subscribe to channel `ch`; `args` are what the app passed to `subscribe` before the
10
- * handler — the channel's `groupBy` reads them.
10
+ * handler — the channel's `groupBy` reads them. With `computed: true`, to the computed
11
+ * `ch` (../server/computed.ts): `args` are its arguments.
11
12
  * → unsub
12
13
  * ← session a token minted while the socket was served (the first contact of a guest): the client
13
14
  * stores it; nothing is dropped, the server already has it.
14
15
  * ← ok the subscription stands; `group` is where the server put it (null = a channel without
15
- * groups). The client learns its group only here — it never names one.
16
- * ← err refused (a hook threw) — the subscription is gone.
16
+ * groups). The client learns its group only here — it never names one. A computed's
17
+ * group is a number the server made for that one subscription, and `data` its value.
18
+ * ← err refused (a hook threw) — the subscription is gone. For a computed: refused when it comes
19
+ * before the `ok` (the first run threw); after it, a later run failed and the
20
+ * subscription stands.
17
21
  * ← ev one published message, addressed by channel + group so the server sends one frame to
18
- * every socket of a group; the client hands it to each of its subscriptions there.
22
+ * every socket of a group; the client hands it to each of its subscriptions there. For a
23
+ * computed: its next value, sent only when it differs from the last one.
19
24
  *
20
25
  * A group's value is not a secret: its subscriber is told it.
21
26
  */
@@ -29,6 +34,7 @@ export type ClientFrame = {
29
34
  id: number;
30
35
  ch: string;
31
36
  args: unknown[];
37
+ computed?: true;
32
38
  } | {
33
39
  t: "unsub";
34
40
  id: number;
@@ -40,6 +46,7 @@ export type ServerFrame = {
40
46
  t: "ok";
41
47
  id: number;
42
48
  group: ChannelGroup | null;
49
+ data?: unknown;
43
50
  } | {
44
51
  t: "err";
45
52
  id: number;
@@ -22,6 +22,11 @@
22
22
  * `globalThis.__lecodesPublish` seam the host installs (same reason as ./context.ts: the bundle
23
23
  * carries its own SDK copy). In the app the export is another object altogether — the proxy
24
24
  * `__channel` of src/runtime/rpc.ts — which is why `subscribe` here only throws.
25
+ *
26
+ * Presence: `roomChannel.members(code)` (and a computed's `watchers(...args)`, ./computed.ts) — the
27
+ * sessions subscribed to a group now. The hub (./channelHub.ts) holds that table; the read goes to it
28
+ * through the `globalThis.__lecodesPresence` seam the host installs (./computedHost.ts
29
+ * `setPresenceProvider`), keyed by the export and the subscription's key as JSON (`presenceKey`).
25
30
  */
26
31
  import type { ChannelGroup } from "../runtime/wire";
27
32
  export type { ChannelGroup };
@@ -48,6 +53,9 @@ export interface Channel<M> {
48
53
  groupBy<K extends ChannelGroup, A extends unknown[] = []>(group: (...args: A) => K | Promise<K>): GroupedChannel<M, K, A>;
49
54
  /** Server: send `message` to every subscriber (at most once — nothing is kept for a socket that is away). */
50
55
  publish(message: M): void;
56
+ /** Server: the sessions (`request.session`) subscribed now, each once. Inside a `computed`, a join or a
57
+ * leave runs it again. */
58
+ members(): Promise<number[]>;
51
59
  /** App: listen until `close()`. */
52
60
  subscribe(handler: (message: M) => void, options?: SubscribeOptions): ChannelSubscription;
53
61
  }
@@ -55,12 +63,20 @@ export interface Channel<M> {
55
63
  export interface GroupedChannel<M, K extends ChannelGroup, A extends unknown[]> {
56
64
  /** Server: send `message` to the subscribers of `group`. */
57
65
  publish(group: K, message: M): void;
66
+ /** Server: the sessions (`request.session`) subscribed to `group` now, each once. Inside a `computed`,
67
+ * a join or a leave runs it again. */
68
+ members(group: K): Promise<number[]>;
58
69
  /** App: listen until `close()`; the arguments before the handler go to the channel's `groupBy`. */
59
70
  subscribe(...args: [...args: A, handler: (message: M) => void, options?: SubscribeOptions]): ChannelSubscription;
60
71
  }
61
72
  type Publisher = (channelId: string, group: ChannelGroup | null, message: unknown) => void;
62
73
  /** Host hook (runner / local backend / test harness): where `publish` delivers to. */
63
74
  export declare const setChannelPublisher: (publisher: Publisher | null) => void;
75
+ /** What a presence read asks the host: the sessions subscribed to export `id` under `key` (`presenceKey`). */
76
+ export type PresenceAsk = (id: string, key: string) => Promise<number[]>;
77
+ /** The key a subscription is present under, the hub's and the reads' alike: a computed's arguments, a
78
+ * channel's group (null without groups) — as JSON. */
79
+ export declare const presenceKey: (what: unknown) => string;
64
80
  export declare const isChannelGroup: (v: unknown) => v is ChannelGroup;
65
81
  export declare const channel: <M = unknown>() => Channel<M>;
66
82
  export declare const isChannel: (v: unknown) => v is ChannelRecord;
@@ -0,0 +1,59 @@
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
+ import { type ChannelSubscription } from "./channel";
38
+ export type ComputedOptions = {
39
+ /** The server refused the subscription (its first run threw), or a later run failed: an `RpcError`,
40
+ * its `status` the thrown one. Without it the failure is logged. A refused subscription is asked
41
+ * again when the session changes; after a failed later run the last value stands and the next change
42
+ * runs it again. */
43
+ error?: (e: Error & {
44
+ readonly status: number;
45
+ }) => void;
46
+ };
47
+ /** A server function the runtime keeps fresh: `computed(async (…args) => value)`. */
48
+ export interface ServerComputed<A extends unknown[], R> {
49
+ /** One call, as an endpoint's. */
50
+ (...args: A): Promise<R>;
51
+ /** App: `handler` gets the value now, and again each time it changes, until `close()`; the arguments
52
+ * before it are the function's. */
53
+ subscribe(...args: [...args: A, handler: (value: R) => void, options?: ComputedOptions]): ChannelSubscription;
54
+ /** Server: the sessions (`request.session`) subscribed to it with these arguments now, each once — the
55
+ * arguments as the app passed them, matched as JSON. Inside a `computed`, a join or a leave runs it again. */
56
+ watchers(...args: A): Promise<number[]>;
57
+ }
58
+ export declare const computed: <A extends unknown[], R>(fn: (...args: A) => Promise<R>) => ServerComputed<A, R>;
59
+ export declare const isComputed: (v: unknown) => v is ComputedRecord;
@@ -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
  export type RequestContext = {
9
13
  /** Endpoint id `<path>#<export>` (or the channel id for a subscription's hooks). */
@@ -14,12 +18,17 @@ export type RequestContext = {
14
18
  sessionToken?: string;
15
19
  /** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
16
20
  auth?: unknown;
21
+ /** The device of the request — `request.session`. Defined by the host runtime (./runtime.ts), read lazily:
22
+ * a host never sets it. */
23
+ readonly session?: number;
17
24
  /** The files a multipart call carried, as the receiving process left them (./files/host.ts `Upload`). */
18
25
  uploads?: unknown[];
19
26
  /** What `/files/<id>/<name>` is appended to in a stored file's url: the backend's own base, as this request reached it. */
20
27
  filesBase?: string;
21
28
  };
22
- type Provider = () => RequestContext | undefined;
29
+ /** `quiet`: the host's own read, not the code's (a stored file's url is made of the request's `filesBase`).
30
+ * Every other read is the code's: inside a computed it makes the run its subscriber's alone (./computedHost.ts). */
31
+ type Provider = (quiet?: boolean) => RequestContext | undefined;
23
32
  /** Host hook (runner / test harness): install the current-request provider. */
24
33
  export declare const setRequestProvider: (provider: Provider | null) => void;
25
34
  /** The current request (server global). Undefined members outside a request. */
@@ -27,5 +36,8 @@ export declare const request: {
27
36
  readonly id: string | undefined;
28
37
  readonly headers: Record<string, string>;
29
38
  readonly ip: string | undefined;
39
+ /** The calling device: a number that stays the same for as long as the app keeps its storage, without
40
+ * sign-in; with `withAuth` it is `db.auth.sessionId`. Not a secret — the token behind it is. */
41
+ readonly session: number | undefined;
30
42
  };
31
43
  export {};
@@ -80,7 +80,8 @@ export interface Field<T, M extends FieldMeta = FieldMeta> {
80
80
  unique(group?: string): Field<T, M>;
81
81
  /** `@index` — used by `$where`, `$order`, aggregations. */
82
82
  index(): Field<T, M>;
83
- /** `@default(...)`; `"now"` on `t.date()` → `now()`. Makes the field optional on insert. */
83
+ /** `@default(...)`; `"now"` on `t.date()` → `now()`, a number is milliseconds since 1970; a json's is
84
+ * any JSON value (`t.json<number[]>().default([])`). Makes the field optional on insert. */
84
85
  default(value: DefaultOf<T, M>): Field<T, SetMeta<M, "hasDefault", true>>;
85
86
  /** Primitive list (`String[]`). */
86
87
  array(): Field<T, SetMeta<M, "array", true>>;
@@ -197,6 +197,39 @@ export type Settles<T> = PromiseLike<T> & {
197
197
  export type Op<T> = Settles<T> & {
198
198
  readonly [__op]: T;
199
199
  };
200
+ /** A running watch — see `ReadOp.watch`. */
201
+ export interface Watch {
202
+ /** Stops it: no callback after this returns. Idempotent. */
203
+ close(): void;
204
+ }
205
+ export type WatchOptions = {
206
+ /** Gets what a read of the watch failed with (an op the database refuses — after a migration, say),
207
+ * and what the callback threw. Without it, they are logged. */
208
+ error?: (error: unknown) => void;
209
+ };
210
+ /** A read (`select`, `first`, `count`, `aggregate`): an op that can also be watched. */
211
+ export type ReadOp<T> = Op<T> & {
212
+ /**
213
+ * Calls `callback` with the result at once, then again each time a commit changed it. The database signals
214
+ * every commit that may have changed it; the client reads again and calls back only when the result
215
+ * differs (as JSON) from the one it delivered last. Signals that arrive during a read make one more read.
216
+ * A dropped connection is reopened and read again by the client — nothing to handle outside.
217
+ */
218
+ watch(callback: (result: T) => void, options?: WatchOptions): Watch;
219
+ };
220
+ /** What a transport tells a watch it holds (internal: `op.watch` is built on it). */
221
+ export type WatchListener = {
222
+ /** A commit may have changed the result — or the transport recovered a lost connection. */
223
+ changed(): void;
224
+ /** The database refused the op when the transport registered it again. */
225
+ failed(error: unknown): void;
226
+ };
227
+ /** A watch registered with a transport: `ready` settles once the database has it (it rejects with the
228
+ * refusal), `close` drops it. */
229
+ export type WatchRegistration = {
230
+ ready: Promise<void>;
231
+ close(): void;
232
+ };
200
233
  export type MarciOp = {
201
234
  model: string;
202
235
  action: string;
@@ -218,6 +251,16 @@ export type JournalArgs = {
218
251
  export type MarciTransport = {
219
252
  exec(op: MarciOp): Promise<any>;
220
253
  batch(ops: MarciOp[]): Promise<any[]>;
254
+ /** The binary read fast path, if the transport has one. `schemaHash` is the fingerprint of the schema the
255
+ * calling client was generated from; the transport answers with the binary result buffer only for a
256
+ * database on the same schema. `{ json }` is an already-parsed JSON answer (HTTP, when the server declined
257
+ * binary); `null` means "no binary answer" and the client falls back to `exec`. */
258
+ queryBinary?(op: MarciOp, schemaHash: string): Promise<Uint8Array | {
259
+ json: any;
260
+ } | null>;
261
+ /** Watches, if the transport has them: registers the read `op` and tells `listener` whenever a commit may
262
+ * have changed its result. Only signals — the re-read and the comparison are the query layer's. */
263
+ watch?(op: MarciOp, listener: WatchListener): WatchRegistration;
221
264
  };
222
265
  export type ModelTypes = {
223
266
  name: string;
@@ -259,6 +302,8 @@ export type IdArg<I> = I | BareId<I>;
259
302
  */
260
303
  export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles<Rows<T, Sel>[]> {
261
304
  readonly [__op]: Rows<T, Sel>[];
305
+ /** The rows now, then each time a commit changed them — see `ReadOp.watch`. */
306
+ watch(callback: (rows: Rows<T, Sel>[]) => void, options?: WatchOptions): Watch;
262
307
  readonly [__sub]: T["name"];
263
308
  readonly [__sel]: Effective<T, Sel>;
264
309
  /** Filter (marcidb `$where`). Repeated calls are ANDed. */
@@ -279,18 +324,18 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles
279
324
  */
280
325
  select<S extends T["query"] = T["scalars"]>(shape?: S): Query<T, S>;
281
326
  /** The first matching row or `null`. */
282
- first(): Op<Rows<T, Sel> | null>;
327
+ first(): ReadOp<Rows<T, Sel> | null>;
283
328
  /** Row count; inside a select it becomes `{ count }` for the relation. */
284
- count(): Op<number> & Sub<T["name"], {
329
+ count(): ReadOp<number> & Sub<T["name"], {
285
330
  $count: true;
286
331
  }>;
287
332
  /** @deprecated pass the filter through the chain: `db.user.where(w).count()` (removed in the next minor). */
288
333
  count(query: {
289
334
  $where?: T["where"];
290
- }): Op<number> & Sub<T["name"], {
335
+ }): ReadOp<number> & Sub<T["name"], {
291
336
  $count: true;
292
337
  }>;
293
- aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>;
338
+ aggregate<A extends T["aggregate"]>(query: A): ReadOp<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>;
294
339
  insert(data: T["insert"]): Op<T["id"]>;
295
340
  /** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
296
341
  insert<S extends T["query"]>(data: T["insert"], options: {
@@ -322,9 +367,9 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Settles
322
367
  */
323
368
  deleteMany(): Op<number>;
324
369
  /** @deprecated use `.select(query)` — it takes the same object, merged with the chain (removed in the next minor). */
325
- findMany<Q extends T["query"] = {}>(query?: Q): Op<Rows<T, Q>[]>;
370
+ findMany<Q extends T["query"] = {}>(query?: Q): ReadOp<Rows<T, Q>[]>;
326
371
  /** @deprecated use `.select(query).first()` (removed in the next minor). */
327
- findFirst<Q extends T["query"] = {}>(query?: Q): Op<Rows<T, Q> | null>;
372
+ findFirst<Q extends T["query"] = {}>(query?: Q): ReadOp<Rows<T, Q> | null>;
328
373
  }
329
374
  /** What a journal can record. Only deletes so far. */
330
375
  export type JournalOp = "delete";
@@ -379,6 +424,8 @@ export type QueryLayerOptions = {
379
424
  /** Hook for the `onInsert` part of an `upsert` — the fields used only when the row is created (the lecodes SDK
380
425
  * puts a uuid id here). Receives what the caller gave (or undefined), returns what is sent. */
381
426
  prepareUpsert?(model: string, onInsert: any): any;
427
+ /** The transport's watches (`MarciTransport.watch`); without it `op.watch` throws. */
428
+ watch?(op: MarciOp, listener: WatchListener): WatchRegistration;
382
429
  };
383
430
  /**
384
431
  * The query builder over a set of models and a `run` function. Returns `op(descriptor)` — a lazy operation
@@ -2,8 +2,10 @@ export { t, model, defineDb, ref } from "./db";
2
2
  export { ApiError } from "./errors";
3
3
  export { env, type Env } from "./env";
4
4
  export { channel } from "./channel";
5
+ export { computed } from "./computed";
5
6
  export { pushChannel, type PushChannel, type PushMessage, type PushNotification, type PushTarget } from "./pushChannel";
6
7
  export { request } from "./context";
8
+ export { ordered } from "./ordered";
7
9
  export type { StoredFile, StoredImage } from "./files/models";
8
10
  /** The value an SVG source is to the client (`assetIcon(…)` compiles to `SvgSource(…)`): a shared module of
9
11
  * the design that names an icon per row loads on the server too. The server draws nothing with it. */
@@ -0,0 +1,18 @@
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
+ export type OrderedKey = string | number;
17
+ /** Run `fn` once every earlier call of `key` has finished; its result (or failure) is the call's. */
18
+ export declare const ordered: <T>(key: OrderedKey, fn: () => T | Promise<T>) => Promise<T>;
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "2.6.0";
1
+ export declare const SDK_VERSION = "2.8.0";
2
2
  /** The major of a semver string, or null when it is not one. */
3
3
  export declare const sdkMajor: (version: string | null | undefined) => number | null;
4
4
  /** The `// sdk: <version>` line of a compiled bundle's header (sdk/src/compile/header.ts), read from