lecodes-sdk 2.6.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/global.d.ts +11 -7
  2. package/dist/types/inject.d.ts +2 -1
  3. package/dist/types/plugin.d.ts +1 -0
  4. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
  5. package/dist/types/runtime/firstCall.d.ts +12 -0
  6. package/dist/types/runtime/gestures.d.ts +52 -0
  7. package/dist/types/runtime/rpc.d.ts +24 -0
  8. package/dist/types/runtime/wire.d.ts +11 -4
  9. package/dist/types/server/channel.d.ts +16 -0
  10. package/dist/types/server/computed.d.ts +59 -0
  11. package/dist/types/server/context.d.ts +13 -1
  12. package/dist/types/server/db/fields.d.ts +2 -1
  13. package/dist/types/server/db/marci/query.d.ts +53 -6
  14. package/dist/types/server/inject.d.ts +2 -0
  15. package/dist/types/server/ordered.d.ts +18 -0
  16. package/dist/types/ui/UIModal.d.ts +5 -3
  17. package/dist/types/version.d.ts +1 -1
  18. package/dist/types.json +1 -1
  19. package/package.json +2 -2
  20. package/src/bridges/tree.d.ts +14 -4
  21. package/src/compile/serverSplit.ts +8 -4
  22. package/src/compile/serverTypes.ts +17 -3
  23. package/src/inject.ts +5 -2
  24. package/src/plugin.ts +3 -1
  25. package/src/plugins/gen/ar/sdk/ar.ts +59 -14
  26. package/src/runtime/firstCall.ts +28 -0
  27. package/src/runtime/gestures.ts +190 -0
  28. package/src/runtime/rpc.ts +141 -12
  29. package/src/runtime/wire.ts +11 -6
  30. package/src/server/channel.ts +32 -0
  31. package/src/server/channelHub.ts +246 -16
  32. package/src/server/clock.ts +50 -0
  33. package/src/server/computed.ts +84 -0
  34. package/src/server/computedHost.ts +413 -0
  35. package/src/server/context.ts +14 -2
  36. package/src/server/db/defineDb.ts +47 -7
  37. package/src/server/db/fields.ts +2 -1
  38. package/src/server/db/httpTransport.ts +37 -2
  39. package/src/server/db/httpWatch.ts +180 -0
  40. package/src/server/db/marci/query.ts +112 -7
  41. package/src/server/db/watch.ts +160 -0
  42. package/src/server/files/host.ts +2 -1
  43. package/src/server/host.ts +19 -4
  44. package/src/server/inject.ts +6 -2
  45. package/src/server/ordered.ts +41 -0
  46. package/src/server/rateLimit.ts +72 -0
  47. package/src/server/runtime.ts +127 -9
  48. package/src/ui/UIModal.ts +44 -9
  49. package/src/ui/UINode.ts +4 -0
  50. package/src/ui/tree.ts +8 -0
  51. package/src/version.ts +1 -1
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
@@ -246,6 +248,7 @@ declare global {
246
248
  const tileOriented: typeof SDK.tileOriented
247
249
  const tileTurns: typeof SDK.tileTurns
248
250
  const toast: typeof SDK.toast
251
+ const trackGesture: typeof SDK.trackGesture
249
252
  const use: typeof SDK.use
250
253
  type ARError = SDK.ARError
251
254
  type ARErrorCode = SDK.ARErrorCode
@@ -376,6 +379,9 @@ declare global {
376
379
  type GeoPosition = SDK.GeoPosition
377
380
  type GeoWatch = SDK.GeoWatch
378
381
  type Geometry = SDK.Geometry
382
+ type Gesture = SDK.Gesture
383
+ type GestureHandler = SDK.GestureHandler
384
+ type GestureTarget = SDK.GestureTarget
379
385
  type GizmoAnchor = SDK.GizmoAnchor
380
386
  type GizmoStyle = SDK.GizmoStyle
381
387
  type HapticStyle = SDK.HapticStyle
@@ -724,13 +730,11 @@ declare global {
724
730
  type Wrap2D = SDK.Wrap2D
725
731
  // ---- server globals (available in *.server.ts; using them in client code is a build error) ----
726
732
  const ApiError: typeof SERVER.ApiError
727
- const SvgSource: typeof SERVER.SvgSource
728
733
  const channel: typeof SERVER.channel
729
734
  const defineDb: typeof SERVER.defineDb
730
- const env: typeof SERVER.env
731
735
  const model: typeof SERVER.model
736
+ const ordered: typeof SERVER.ordered
732
737
  const pushChannel: typeof SERVER.pushChannel
733
- const ref: typeof SERVER.ref
734
738
  const request: typeof SERVER.request
735
739
  const t: typeof SERVER.t
736
740
  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";
@@ -37,6 +37,7 @@ export { audio, type PlaySoundOptions, type SpatialOptions, type Rolloff, type A
37
37
  export { openFilePicker, files, type FileData } from "./runtime/files";
38
38
  export { share } from "./runtime/share";
39
39
  export { ClickEvent, TouchStartEvent, LongPressEvent, MouseEnterEvent } from "./runtime/touch";
40
+ export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures";
40
41
  export { Scene2D } from "./g2/Scene2D";
41
42
  export { Node2D } from "./g2/Node2D";
42
43
  export { Camera2D } from "./g2/Camera2D";
@@ -22,6 +22,7 @@ export { Vec2, Vec3, type Vec3Like } from "./math/vec";
22
22
  export { Quat, type QuatLike } from "./math/quat";
23
23
  export { Mat4 } from "./math/mat4";
24
24
  export type { ClickEvent, TouchStartEvent } from "./runtime/touch";
25
+ export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures";
25
26
  export type { DismissOptions } from "./ui/presentable";
26
27
  /** The seam the push plugin's wrapper installs its runtime on (src/runtime/pushSeam.ts): what the app's transport
27
28
  * reads the device's address from, registers through, and a push channel's handlers are kept by. */
@@ -44,6 +44,9 @@ export type ARSceneOptions = SceneOptions & {
44
44
  /** "world" (default): planes, hit tests, anchors, image and QR targets. "images": image and QR
45
45
  * targets only — lighter on iOS; no planes, no hit test, no `root`. */
46
46
  mode?: ARMode;
47
+ /** How far in front of the user `root` is placed, metres (default 1): along the camera's heading,
48
+ * on the floor. */
49
+ rootDistance?: number;
47
50
  };
48
51
  export type AROpenOptions = PresentOptions & Omit<StartOptions, "mode" | "images" | "codes"> & {
49
52
  /** The scene's ambient (its `environmentIntensity`) follows the estimated light; turns
@@ -128,6 +131,8 @@ export declare class ARScene extends Scene {
128
131
  /** Whether this host registered the "ar" service (the app lists the plugin, the host has a half). */
129
132
  static get isSupported(): boolean;
130
133
  readonly mode: ARMode;
134
+ /** Metres between the user and `root` when it is placed (`rootDistance` option, default 1). */
135
+ readonly rootDistance: number;
131
136
  /** `open()` warm-renders the scene first (default true). */
132
137
  useWarmRender: boolean;
133
138
  private _opening;
@@ -147,7 +152,8 @@ export declare class ARScene extends Scene {
147
152
  private _codesWarned;
148
153
  private _options;
149
154
  private _root;
150
- /** The first horizontal plane's pose: where `root` is pinned. */
155
+ /** The floor's height (world Y, metres): the first horizontal plane below the camera this session
156
+ * found. Null until one is; `root` is placed on it. */
151
157
  private _floor;
152
158
  /** The image targets. `source` is held for the bytes: a fetched response owns its buffer, which the
153
159
  * host frees once the response is collected — the File the session is handed only names it. */
@@ -174,9 +180,17 @@ export declare class ARScene extends Scene {
174
180
  /** A grid drawn on every plane (each plane's child mesh). */
175
181
  get showPlanes(): boolean;
176
182
  set showPlanes(on: boolean);
177
- /** The floor: an anchor pinned on the first horizontal plane the session finds (it fires `track`
178
- * then). Until that it is the world's origin — where the session began. Not in "images" mode. */
183
+ /** The place in front of the user: an anchor `rootDistance` metres ahead of the camera along its
184
+ * heading, on the floor — the first horizontal plane below the camera the session finds; it is
185
+ * pinned there and fires `track` then — turned the way the user looks (its −Z along the heading,
186
+ * so a model's front, +Z, faces the user). Until the floor is found (and on the web, which finds
187
+ * none) it sits the same way at the camera's own height, not pinned. Made on first use; put
188
+ * content under it. `placeRoot()` puts it in front of the user again. Not in "images" mode. */
179
189
  get root(): Anchor;
190
+ /** Put `root` in front of the user again — where they look now, `rootDistance` metres ahead, on the
191
+ * floor if one is known (the camera's height otherwise), facing them: a "reset" button, or a
192
+ * placement after the user has moved. Makes `root` when there is none yet. */
193
+ placeRoot(): Anchor;
180
194
  /** An anchor that follows an image (a printed marker, a poster): the session finds it in the camera
181
195
  * image and keeps the anchor on it — a rigid pose in metres (its centre, Y out of the image, the
182
196
  * image in XZ), its size `anchor.extent`. `physicalWidth` = the printed width in metres. Hidden
@@ -244,8 +258,14 @@ export declare class ARScene extends Scene {
244
258
  private _wire;
245
259
  /** Pin `anchor` at `pose` (where it is until the session holds it) — now, or when a session starts. */
246
260
  private _place;
247
- /** `root` onto the floor (the first horizontal plane of this session). */
248
- private _pinRoot;
261
+ /** Where `root` goes now: `rootDistance` metres from the camera along its heading (its forward
262
+ * flattened to the floor; looking straight down or up, the way the top of the screen points), at
263
+ * the floor's height when one is known, else the camera's; turned so its −Z runs along the
264
+ * heading — a model's front (+Z) faces the user. */
265
+ private _rootPose;
266
+ /** `root` in front of the user: moved there now, and pinned there when the floor is known (a
267
+ * session holds a pin; without one `_pinAll` does not re-pin root — it waits for its own floor). */
268
+ private _placeRoot;
249
269
  private _pin;
250
270
  /** A session began: every pose anchor is pinned where it is. */
251
271
  private _pinAll;
@@ -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;
@@ -0,0 +1,52 @@
1
+ import type { TouchStartEvent, TrackHandler } from "./touch";
2
+ /** One reading of a gesture, handed to `onStart` / `onMove` / `onEnd` (a fresh object each time). */
3
+ export interface Gesture {
4
+ /** The centroid of the live fingers, in the event's client coordinates (logical px). */
5
+ centerX: number;
6
+ centerY: number;
7
+ /** The centroid's move since the previous `onMove` (0 on start / end). */
8
+ deltaX: number;
9
+ deltaY: number;
10
+ /** Mean finger distance to the centroid over the previous one (1 with one finger, 1 on start / end). */
11
+ scale: number;
12
+ /** The mean change of each finger's angle about the centroid since the previous `onMove`, radians,
13
+ * positive = clockwise on screen (0 with one finger, 0 on start / end). */
14
+ rotation: number;
15
+ /** The number of live fingers (0 in `onEnd`: the last one lifted). */
16
+ pointers: number;
17
+ }
18
+ /** What `trackGesture` takes: the gesture's callbacks and the claim every finger's track makes. */
19
+ export interface GestureHandler {
20
+ /** Passed to every finger's `ev.track(...)` (default `true`: a scrollable above never takes the pan). */
21
+ claim?: TrackHandler["claim"];
22
+ /** The first finger landed. */
23
+ onStart?(g: Gesture): void;
24
+ /** Any finger moved: the change since the previous `onMove`. */
25
+ onMove?(g: Gesture): void;
26
+ /** The last finger lifted. */
27
+ onEnd?(g: Gesture): void;
28
+ /** The host cancelled one of the fingers: the whole gesture is over. */
29
+ onCancel?(): void;
30
+ }
31
+ /** Anything that hands out touchstarts: a UI element (`onTouchStart`) or a scene / node
32
+ * (`addEventListener("touchstart", …)`). */
33
+ export type GestureTarget = {
34
+ onTouchStart(callback: (ev: TouchStartEvent<any>) => void): unknown;
35
+ } | {
36
+ addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void;
37
+ removeEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void;
38
+ };
39
+ /**
40
+ * Follow pan, pinch and twist on `target` as one gesture: every finger that lands on it is tracked,
41
+ * and `onMove` reports the change of the whole set since the previous move — the centroid's move,
42
+ * the spread's ratio, the twist's angle. One finger is a gesture too (a pan, scale 1, rotation 0).
43
+ *
44
+ * ```ts
45
+ * trackGesture(scene, {
46
+ * onMove(g) { zoom *= g.scale; angle += g.rotation; panX += g.deltaX; panY += g.deltaY },
47
+ * })
48
+ * ```
49
+ */
50
+ export declare const trackGesture: (target: GestureTarget, handler: GestureHandler) => {
51
+ remove(): void;
52
+ };
@@ -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>;