lecodes-sdk 2.2.3 → 2.2.4

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.
@@ -29,12 +29,12 @@
29
29
 
30
30
  import { createReadStream } from "node:fs"
31
31
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
32
- import { join } from "node:path"
32
+ import { basename, join } from "node:path"
33
33
  import { Readable } from "node:stream"
34
34
  import { currentRequest, type RequestContext } from "../context"
35
35
  import type { MarciOp, MarciTransport } from "../db/marci/query"
36
36
  import type { Db } from "../db/types"
37
- import { FILE_MODEL, setFilesSeam, type FilesSeam, type ImageFacts, type ImageRule } from "./models"
37
+ import { FILE_MODEL, setFilesSeam, type FilesSeam, type ImageFacts, type ImageRule, type UploadedFile } from "./models"
38
38
 
39
39
  /** What the receiving process knows of one uploaded file; its bytes are in `<dir>/tmp/<id>`. */
40
40
  export type Upload = { id: string, name: string, type: string, size: number, width?: number, height?: number }
@@ -344,10 +344,18 @@ export type FilesHost = {
344
344
  install(): void
345
345
  /** The arguments with every `{ "$file": i }` turned into the `File` the endpoint receives. */
346
346
  adopt(args: unknown[], rc: RequestContext): Promise<unknown[]>
347
+ /** While a seed runs (runtime.ts runSeed): a resource of the project a row names by its path is read
348
+ * through `read` and stored as an upload of `rc` would be — the field's rule applies, what the seed
349
+ * writes nowhere is dropped by `finish`. Returns what ends it. */
350
+ seeding(rc: RequestContext, read: ResourceReader): () => void
347
351
  /** After the call, whatever it ended with. */
348
352
  finish(rc: RequestContext): Promise<void>
349
353
  }
350
354
 
355
+ /** A resource of the project by the path the bundle has for it (`/images/a.jpg`): its bytes, or null when
356
+ * the project has no such file. The host knows where the project's files are — a folder, a url. */
357
+ export type ResourceReader = (path: string) => Promise<Uint8Array | null>
358
+
351
359
  const isPlaceholder = (v: unknown): v is { $file: number } =>
352
360
  typeof v === "object" && v !== null && typeof (v as { $file?: unknown }).$file === "number" && Object.keys(v).length === 1
353
361
 
@@ -434,10 +442,44 @@ export const createFilesHost = (cfg: FilesHostConfig): FilesHost => {
434
442
  }
435
443
  })().catch((e) => { ready = null; throw e }))
436
444
 
445
+ /** Uploads that lie in `tmp` become rows of `File` and files in place — the rows first, so a file on
446
+ * disk always has its row — and the `File` objects the call sees, each known to the seam. */
447
+ const admit = async (uploads: Upload[], rc: RequestContext): Promise<UploadedFile[]> => {
448
+ await transport().batch(uploads.map(u => ({
449
+ model: FILE_MODEL, action: "insert",
450
+ data: { id: u.id, name: u.name, type: u.type, size: u.size, ...(u.width ? { width: u.width, height: u.height } : {}) },
451
+ })))
452
+ inFlight.set(rc, [...(inFlight.get(rc) ?? []), ...uploads.map(u => u.id)])
453
+ for (const u of uploads) names.set(u.id, u.name)
454
+ for (const u of uploads) await rename(join(cfg.dir, "tmp", u.id), join(cfg.dir, u.id))
455
+ return uploads.map((u) => {
456
+ const file = Object.freeze({ name: u.name, size: u.size, type: u.type })
457
+ uploaded.set(file, u.id)
458
+ return file
459
+ })
460
+ }
461
+
437
462
  return {
438
463
  enabled,
439
464
  install: () => setFilesSeam(enabled ? seam : null),
440
465
 
466
+ seeding: (rc, read) => {
467
+ if (!enabled) return () => {}
468
+ // a file belongs to one row: a picture two rows name is stored for each of them
469
+ seam.resource = async (path) => {
470
+ const bytes = await read(path)
471
+ if (!bytes) return undefined
472
+ await start()
473
+ const id = crypto.randomUUID()
474
+ const type = sniffType(bytes) ?? "application/octet-stream"
475
+ await writeFile(join(cfg.dir, "tmp", id), bytes)
476
+ const size = imageSize(bytes, type)
477
+ const [file] = await admit([{ id, name: basename(path), type, size: bytes.length, ...(size ?? {}) }], rc)
478
+ return uploaded.get(file!)
479
+ }
480
+ return () => { delete seam.resource }
481
+ },
482
+
441
483
  adopt: async (args, rc) => {
442
484
  const uploads = (rc.uploads ?? []) as Upload[]
443
485
  if (!uploads.length && !hasPlaceholder(args)) return args
@@ -448,21 +490,7 @@ export const createFilesHost = (cfg: FilesHostConfig): FilesHost => {
448
490
  const used = (await exec({ action: "aggregate", query: { $sum: "size" } }))?.sum ?? 0
449
491
  if (used + uploads.reduce((n, u) => n + u.size, 0) > cfg.quota) throw new UploadError(413, "the project's file storage is full")
450
492
  }
451
- // the rows first, then the bytes into place: a file on disk always has its row
452
- if (uploads.length) {
453
- await transport().batch(uploads.map(u => ({
454
- model: FILE_MODEL, action: "insert",
455
- data: { id: u.id, name: u.name, type: u.type, size: u.size, ...(u.width ? { width: u.width, height: u.height } : {}) },
456
- })))
457
- inFlight.set(rc, uploads.map(u => u.id))
458
- for (const u of uploads) names.set(u.id, u.name)
459
- for (const u of uploads) await rename(join(cfg.dir, "tmp", u.id), join(cfg.dir, u.id))
460
- }
461
- const files = uploads.map((u) => {
462
- const file = Object.freeze({ name: u.name, size: u.size, type: u.type })
463
- uploaded.set(file, u.id)
464
- return file
465
- })
493
+ const files = uploads.length ? await admit(uploads, rc) : []
466
494
  const place = (v: unknown): unknown => {
467
495
  if (isPlaceholder(v)) return files[v.$file] ?? (() => { throw new UploadError(400, `the call names file ${v.$file}, and carries ${files.length}`) })()
468
496
  if (Array.isArray(v)) return v.map(place)
@@ -96,6 +96,10 @@ export type FilesSeam = {
96
96
  /** The id of the `File` row behind an upload of the current request; undefined for anything else —
97
97
  * a value the app sent as JSON is never an upload, whatever it looks like. */
98
98
  uploaded(value: unknown): string | undefined
99
+ /** While a seed runs: the id of a `File` row made from a resource of the project named by its path
100
+ * (what a path literal of the design is in the server bundle); undefined for a path that is no file
101
+ * of the project. Absent outside a seed: a string the app sent is never a file. */
102
+ resource?(path: string): Promise<string | undefined>
99
103
  url(id: string, name: string): string
100
104
  /** Make the upload `id` fit for a `t.image()` field (its bytes are rewritten in place) and say what
101
105
  * it is now. Throws an `ApiError` for what is no picture. */
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  export {
9
- loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, setChannelPublisher, setRequestProvider,
9
+ loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, setChannelPublisher, setRequestProvider, runSeed, SeedError,
10
10
  } from "./runtime"
11
- export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration } from "./runtime"
11
+ export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration, SeedOutcome } from "./runtime"
12
12
  export type { RequestContext } from "./context"
13
13
  export { createChannelHub } from "./channelHub"
14
14
  export type { ChannelHub, ChannelHubOptions, HubConnection, HubFanout } from "./channelHub"
@@ -26,7 +26,7 @@ export type { AuthState, AuthOps } from "./auth/types"
26
26
  export type { AppConfig } from "./auth/appConfig"
27
27
  export type { IdentityProvider } from "./auth/models"
28
28
  export { createFilesHost, receiveUploads, dropUploads, fileResponse, isMultipart, UploadError } from "./files/host"
29
- export type { FilesHost, FilesHostConfig, Heic, Sharp, Upload, UploadLimits } from "./files/host"
29
+ export type { FilesHost, FilesHostConfig, Heic, ResourceReader, Sharp, Upload, UploadLimits } from "./files/host"
30
30
  export type { StoredFile, StoredImage } from "./files/models"
31
31
  import { FILE_MODEL, filePublicFields } from "./files/models"
32
32
  import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityPublicFields, sessionPublicFields } from "./auth/models"
@@ -12,3 +12,6 @@ export { ApiError } from "./errors"
12
12
  export { channel } from "./channel"
13
13
  export { request } from "./context"
14
14
  export type { StoredFile, StoredImage } from "./files/models"
15
+ /** The value an SVG source is to the client (`assetIcon(…)` compiles to `SvgSource(…)`): a shared module of
16
+ * the design that names an icon per row loads on the server too. The server draws nothing with it. */
17
+ export const SvgSource = (svg: string): { svg: string, tintColor: string | null } => ({ svg, tintColor: null })
@@ -14,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
14
14
  import type { ServerManifest } from "../compile/serverSplit"
15
15
  import type { AuthHost } from "./auth/host"
16
16
  import type { AuthState } from "./auth/types"
17
- import type { FilesHost } from "./files/host"
17
+ import type { FilesHost, ResourceReader } from "./files/host"
18
18
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
19
19
  import { setRequestProvider, type RequestContext } from "./context"
20
20
  import type { Db } from "./db/types"
@@ -55,7 +55,7 @@ export const loadServerModules = (
55
55
  // One database per project: the host syncs one schema, so the models of a second `defineDb` would not exist.
56
56
  if (out.dbs.length > 1) {
57
57
  // named by their collections (own enumerable keys): reading `$models` would seal a db that is being refused
58
- const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth").map(k => `db.${k}`).join(", ")).join(" | ")
58
+ const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth" && k !== "seed").map(k => `db.${k}`).join(", ")).join(" | ")
59
59
  throw new Error(`a project has one database, and defineDb is called ${out.dbs.length} times (${each}) — put every model in one defineDb({...}) and import that db where it is needed`)
60
60
  }
61
61
  return out
@@ -83,6 +83,19 @@ const apiStatus = (e: unknown): number | null =>
83
83
  e && typeof e === "object" && (e as { name?: string }).name === "ApiError" && typeof (e as { status?: unknown }).status === "number"
84
84
  ? (e as { status: number }).status : null
85
85
 
86
+ /** What the app is told when the database refused a write a person could have caused: the engine's own
87
+ * words — `Like.user, Like.post: the value is already taken (by the row with id 7)` — with the status
88
+ * its kind means. A taken unique value, a pointer at no row, a delete other rows forbid: 409; a row that
89
+ * is gone: 404. Anything else the database says (a bad payload, a fault) stays a 500: a mistake of the
90
+ * code, not of the person. Both transports' errors carry `kind` (marcidb-embedded's, the HTTP one's). */
91
+ const DB_ERROR_STATUS: Record<string, number> = { unique: 409, foreign_key: 409, restrict: 409, duplicate_id: 409, not_found: 404 }
92
+ const dbStatus = (e: unknown): number | null => {
93
+ if (!e || typeof e !== "object") return null
94
+ const { name, kind } = e as { name?: unknown, kind?: unknown }
95
+ if ((name !== "MarciEmbeddedError" && name !== "MarciHttpError") || typeof kind !== "string") return null
96
+ return DB_ERROR_STATUS[kind] ?? null
97
+ }
98
+
86
99
  /**
87
100
  * Build the invoker: `invoke(id, args, ctx)` runs endpoint `id` inside an AsyncLocalStorage-scoped
88
101
  * request context (what the `request` global and `db.auth` read) and returns the wire envelope.
@@ -119,7 +132,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
119
132
  const result = await als.run(rc, () => fn(...args))
120
133
  return withSession({ ok: true, result: result === undefined ? null : result }, rc)
121
134
  } catch (e) {
122
- const status = apiStatus(e)
135
+ const status = apiStatus(e) ?? dbStatus(e)
123
136
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
124
137
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
125
138
  onError(id, e)
@@ -161,6 +174,54 @@ export const createFileWriter = (server: LoadedServer, opts: { files?: FilesHost
161
174
  }
162
175
  }
163
176
 
177
+ /** What stops a seed — the project's own doing, in words the project's author can act on. */
178
+ export class SeedError extends Error {
179
+ constructor(message: string) { super(message); this.name = "SeedError" }
180
+ }
181
+
182
+ export type SeedOutcome =
183
+ /** the definition has no seed */
184
+ | { status: "none" }
185
+ /** the database has rows already: the seed did not run */
186
+ | { status: "skipped" }
187
+ /** the seed ran: the rows of every table of the project */
188
+ | { status: "seeded", rows: Record<string, number> }
189
+
190
+ /**
191
+ * The first rows of the project's database: `defineDb(...).seed(fn)` run ONCE, after the host has
192
+ * brought an EMPTY database to the bundle's schema — a check's, a dev backend's, a deploy's first.
193
+ * A database with any row of the project's own tables is left as it is (the seed is the start, not a
194
+ * migration). Inside the seed a file field takes a resource of the project by its path (what a path
195
+ * literal of a shared module is in the server bundle), read through `read` and stored as an upload
196
+ * would be. Throws a `SeedError` with the seed's own words when it fails — the host reports it, and the
197
+ * database is whatever the seed wrote before (the host that wants an all-or-nothing start starts over).
198
+ */
199
+ export const runSeed = async (server: LoadedServer, opts: { files?: FilesHost | null, read?: ResourceReader } = {}): Promise<SeedOutcome> => {
200
+ installProvider()
201
+ const db = server.dbs[0]
202
+ const fn = db?.$seed
203
+ if (!db || !fn) return { status: "none" }
204
+ const names = Object.keys(db.$given)
205
+ const collection = (name: string) => (db as any)[name.charAt(0).toLowerCase() + name.slice(1)]
206
+ for (const name of names) if ((await collection(name).count()) > 0) return { status: "skipped" }
207
+ const rc: RequestContext = { id: "$seed", headers: {} }
208
+ const files = opts.files ?? null
209
+ const read: ResourceReader = opts.read ?? (async () => null)
210
+ const done = files?.enabled ? files.seeding(rc, read) : () => {}
211
+ try {
212
+ await als.run(rc, () => fn(db as any))
213
+ } catch (e) {
214
+ const message = e instanceof Error ? e.message : String(e)
215
+ throw new SeedError(`seed: ${message}`)
216
+ } finally {
217
+ done()
218
+ if (files?.enabled) await als.run(rc, () => files.finish(rc))
219
+ }
220
+ const rows: Record<string, number> = {}
221
+ for (const name of names) rows[name] = await collection(name).count()
222
+ return { status: "seeded", rows }
223
+ }
224
+
164
225
  /**
165
226
  * A subscription to a channel: run its hooks (`authorize`, then `groupBy`) in the subscriber's
166
227
  * request scope. The envelope's `result` is the group the subscriber is in — `null` for a channel
@@ -187,7 +248,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
187
248
  if (group !== null && !isChannelGroup(group)) throw new Error(`groupBy answered ${group === undefined ? "nothing" : typeof group} — a group is a string or a number`)
188
249
  return withSession({ ok: true, result: group }, rc)
189
250
  } catch (e) {
190
- const status = apiStatus(e)
251
+ const status = apiStatus(e) ?? dbStatus(e)
191
252
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
192
253
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
193
254
  onError(channelId, e)
package/src/ui/UINode.ts CHANGED
@@ -41,6 +41,9 @@ export type Color = ColorInput | null
41
41
  /** Style props every element accepts. */
42
42
  export type BaseStyle = {
43
43
  opacity?: number | `${number}`,
44
+ /** A CSS-style list of `translate` / `scale` / `rotate`. A `%` in a translate is a percent of the
45
+ * element's own box (`translate(-50%, -50%)` centres it on its corner), kept as a percent: it
46
+ * follows the box through every layout. */
44
47
  transform?: string | null,
45
48
  /** Pivot of `transform` (CSS `transform-origin` subset): one or two values, each a keyword
46
49
  * (`left`/`center`/`right`, `top`/`center`/`bottom`), a percentage of the box, or a px length
@@ -10,20 +10,14 @@ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & {
10
10
  keyboardDismissMode?: "interactive" | "scroll" | "none",
11
11
  }
12
12
 
13
- export interface VListConfig<T> {
14
- /** The rows, read reactively: the list follows the signals this reads, the way a container's
15
- * function children do. Rows are told apart by `keyOf`; a row whose item is another object
16
- * re-renders. Appended rows and rows added at the start are told from a rewrite (a chat at
17
- * its bottom follows what is appended; older history above moves nothing). */
18
- data: () => T[]
13
+ interface VListBaseConfig<T> {
19
14
  /** Stable unique key of an item. */
20
15
  keyOf: (item: T) => string
21
16
  /** Subtree factory — called when the item enters the window; the subtree lives until it
22
17
  * leaves. */
23
18
  render: (item: T) => UINodeChild
24
19
  /** Height estimate (px) used before the first measurement — a number, or a per-item function
25
- * (chat: `msg => 40 + Math.ceil(msg.text.length / 35) * 20`). Computed once when the item is
26
- * added. */
20
+ * (chat: `msg => 40 + Math.ceil(msg.text.length / 35) * 20`). Read when the item is added. */
27
21
  estimatedHeight: number | ((item: T) => number)
28
22
  /** Extra px around the viewport kept mounted. Default: one viewport height. */
29
23
  overscan?: number
@@ -32,9 +26,26 @@ export interface VListConfig<T> {
32
26
  inverted?: boolean
33
27
  }
34
28
 
35
- /** A windowed (virtualized) list: only the items near the viewport are mounted — for long feeds
36
- * and chats. Fed by `data`, a function over signals. */
37
- export interface UIVirtualizedList<T = unknown> extends UIElementBase<UIVirtualizedListStyle> {
29
+ /** A list fed by `data`: the rows are read reactively, the way a container's function children
30
+ * are. Such a list has no imperative feed (`append` and the rest are not on its type). */
31
+ export interface VListBoundConfig<T> extends VListBaseConfig<T> {
32
+ /** The rows, read reactively: the list follows the signals this reads. Rows are told apart by
33
+ * `keyOf`; a row whose item is another object re-renders. The change is told from the keys:
34
+ * rows gone are removals, new rows are inserts where they stand (a chat at its bottom follows
35
+ * an append; rows above the reader move nothing), a reorder rewrites the keys. */
36
+ data: () => T[]
37
+ }
38
+
39
+ /** A list fed by hand: `setData` / `append` / `prepend` / `update` / `removeByKey`. */
40
+ export interface VListFedConfig<T> extends VListBaseConfig<T> {
41
+ data?: undefined
42
+ }
43
+
44
+ export type VListConfig<T> = VListBoundConfig<T> | VListFedConfig<T>
45
+
46
+ /** What both forms of the list share: the items held, scrolling, the scroll / refresh / edge
47
+ * callbacks. */
48
+ export interface UIVirtualizedListBase<T = unknown> extends UIElementBase<UIVirtualizedListStyle> {
38
49
  readonly type: "vlist"
39
50
 
40
51
  /** Number of data items (mounted or not). */
@@ -60,7 +71,31 @@ export interface UIVirtualizedList<T = unknown> extends UIElementBase<UIVirtuali
60
71
  onStartReached(thresholdPx: number, callback: () => void): this
61
72
  }
62
73
 
63
- export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedList<T> {
74
+ /** A virtualized list fed by `data` (a function over signals): write the signal, the list follows. */
75
+ export interface UIBoundVirtualizedList<T = unknown> extends UIVirtualizedListBase<T> {}
76
+
77
+ /** A virtualized list fed by hand. Each call goes straight to the runtime: `append` is O(1). */
78
+ export interface UIFedVirtualizedList<T = unknown> extends UIVirtualizedListBase<T> {
79
+ /** Replace the data wholesale — diffed by key like a `data` write (vanished keys are removed,
80
+ * new ones inserted where they stand, a reorder rewrites). */
81
+ setData(items: T[]): this
82
+ /** Append items to the end (auto-scrolls when `inverted` and resting at the bottom). */
83
+ append(...items: T[]): this
84
+ /** Insert at the start WITHOUT a scroll jump (the runtime compensates the offset). */
85
+ prepend(...items: T[]): this
86
+ /** Update items with the same keys: mounted ones re-render; the cached height persists until
87
+ * re-measured. */
88
+ update(...items: T[]): this
89
+ /** Remove items by key. */
90
+ removeByKey(...keys: string[]): this
91
+ }
92
+
93
+ /** A windowed (virtualized) list: only the items near the viewport are mounted — for long feeds
94
+ * and chats. Fed by `data`, a function over signals, or by hand (`append` and the rest); which
95
+ * one is the config's and the type's. */
96
+ export type UIVirtualizedList<T = unknown> = UIBoundVirtualizedList<T> | UIFedVirtualizedList<T>
97
+
98
+ export class VListElement<T> extends Element<"vlist"> implements UIFedVirtualizedList<T> {
64
99
  private readonly sl: ((scrollPosition: number) => void)[] = [] // scroll
65
100
  private readonly erl: (() => void)[] = [] // endReached
66
101
  private readonly strl: (() => void)[] = [] // startReached
@@ -74,6 +109,7 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
74
109
  private estimate: (item: T) => number
75
110
  private readonly _overscan: number
76
111
  private readonly _inverted: boolean
112
+ private readonly _bound: boolean
77
113
  private _endThreshold = 0
78
114
  private _startThreshold = 0
79
115
  private _inited = false
@@ -90,10 +126,12 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
90
126
  : () => config.estimatedHeight as number
91
127
  this._overscan = config.overscan ?? -1
92
128
  this._inverted = config.inverted ?? false
129
+ const data = config.data
130
+ this._bound = data !== undefined
93
131
  // The data binding is the element's (it goes with it); its first run waits a microtask so the
94
132
  // edge thresholds chained after the constructor (`.onEndReached(…)`) are in the runtime's list
95
133
  // state, which is created on the first write.
96
- Promise.resolve().then(() => { if (this._h.id !== 0) createBinding(this, "data", () => this._apply(config.data())) })
134
+ if (data !== undefined) Promise.resolve().then(() => { if (this._h.id !== 0) createBinding(this, "data", () => this._apply(data())) })
97
135
  }
98
136
 
99
137
  // The runtime's list state is created on the first data write — after the app had its chance to
@@ -104,42 +142,68 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
104
142
  tree().vlistInit(this._h.id, this._overscan, this._inverted, this._startThreshold, this._endThreshold)
105
143
  }
106
144
 
107
- /** The data as it is now against what the runtime holds: rows added at the end or at the start
108
- * are inserts (the runtime anchors the scroll: a chat follows an append, history above moves
109
- * nothing), rows gone are removals, anything else a rewrite of the keys (measured heights stay). */
145
+ /** The two feeds are exclusive per list: a hand-made change to a list `data` feeds would be
146
+ * undone by the binding's next run, so it is refused and logged. */
147
+ private _fed(method: string): boolean {
148
+ if (!this._bound) return true
149
+ console.error(`UIVirtualizedList.${method}(): the list is fed by data — write the signal it reads instead`)
150
+ return false
151
+ }
152
+
153
+ /** The data as it is now against what the runtime holds. Keys gone are removals, keys new are
154
+ * inserts where they stand in the new order — both anchored by the runtime (a chat follows an
155
+ * append, rows above the reader move nothing) — as long as the keys kept keep their order;
156
+ * a reorder rewrites the keys (measured heights stay). A mounted row whose item is another
157
+ * object re-renders. */
110
158
  private _apply(next: T[]): void {
111
159
  const prevKeys = this.keys
112
160
  const prevItems = this.items
113
161
  const keys: string[] = []
114
- const estimates: number[] = []
115
162
  const items = new Map<string, T>()
116
163
  for (const item of next) {
117
164
  const k = this.keyOf(item)
118
165
  if (items.has(k)) continue // a repeated key: the first row stands
119
166
  items.set(k, item)
120
167
  keys.push(k)
121
- estimates.push(this.estimate(item))
122
168
  }
123
169
  this.keys = keys
124
170
  this.items = items
125
171
  this._ensure()
126
172
  const id = this._h.id
127
173
  if (id === 0) return
174
+ // the estimates of keys[from, to): read for the rows the runtime is told of, not for every row
175
+ const estimates = (from: number, to: number): number[] => {
176
+ const out: number[] = []
177
+ for (let i = from; i < to; i++) out.push(this.estimate(items.get(keys[i])!))
178
+ return out
179
+ }
128
180
  const n = prevKeys.length, m = keys.length
129
- const same = (from: number) => { for (let i = 0; i < n; i++) if (keys[from + i] !== prevKeys[i]) return false; return true }
130
181
  if (n === 0 || m === 0) {
131
- tree().vlistSetKeys(id, keys, estimates)
132
- } else if (m >= n && same(0)) {
133
- if (m > n) tree().vlistInsertKeys(id, -1, keys.slice(n), estimates.slice(n)) // the same keys: nothing to tell
134
- } else if (m > n && same(m - n)) {
135
- tree().vlistInsertKeys(id, 0, keys.slice(0, m - n), estimates.slice(0, m - n))
182
+ tree().vlistSetKeys(id, keys, estimates(0, m))
136
183
  } else {
137
- // removals only: the new keys are the old ones in order, some left out
184
+ // the keys kept, in the old order, must be the keys kept in the new order
138
185
  const gone: string[] = []
139
- let j = 0
140
- for (const k of prevKeys) { if (j < m && keys[j] === k) j++; else gone.push(k) }
141
- if (m < n && j === m) tree().vlistRemoveKeys(id, gone)
142
- else tree().vlistSetKeys(id, keys, estimates)
186
+ let j = 0, ordered = true
187
+ for (const k of prevKeys) {
188
+ if (!items.has(k)) { gone.push(k); continue }
189
+ while (j < m && !prevItems.has(keys[j])) j++ // skip the new rows
190
+ if (keys[j] !== k) { ordered = false; break }
191
+ j++
192
+ }
193
+ if (!ordered) {
194
+ tree().vlistSetKeys(id, keys, estimates(0, m))
195
+ } else {
196
+ if (gone.length > 0) tree().vlistRemoveKeys(id, gone)
197
+ // runs of new rows, in order: everything before a run is already in the list, so the
198
+ // run's position in the new array is its index in the list as it stands
199
+ for (let i = 0; i < m;) {
200
+ if (prevItems.has(keys[i])) { i++; continue }
201
+ let e = i + 1
202
+ while (e < m && !prevItems.has(keys[e])) e++
203
+ tree().vlistInsertKeys(id, e === m ? -1 : i, keys.slice(i, e), estimates(i, e))
204
+ i = e
205
+ }
206
+ }
143
207
  }
144
208
  // a mounted row whose item is another object re-renders; the others pick theirs up when they re-enter
145
209
  for (const k of this.mounted.keys()) {
@@ -147,6 +211,67 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
147
211
  }
148
212
  }
149
213
 
214
+ /** The new rows of `items` (keys not held yet, repeats dropped), registered. */
215
+ private _register(items: T[]): [string[], number[]] {
216
+ const keys: string[] = []
217
+ const estimates: number[] = []
218
+ for (const item of items) {
219
+ const k = this.keyOf(item)
220
+ if (this.items.has(k)) continue
221
+ this.items.set(k, item)
222
+ keys.push(k)
223
+ estimates.push(this.estimate(item))
224
+ }
225
+ return [keys, estimates]
226
+ }
227
+
228
+ // --- the hand feed: each call is the runtime's operation, no diff ---
229
+ setData(items: T[]): this {
230
+ if (this._fed("setData")) this._apply(items)
231
+ return this
232
+ }
233
+
234
+ append(...items: T[]): this {
235
+ if (!this._fed("append")) return this
236
+ const [keys, estimates] = this._register(items)
237
+ if (keys.length === 0) return this
238
+ this.keys.push(...keys)
239
+ this._ensure()
240
+ if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, -1, keys, estimates)
241
+ return this
242
+ }
243
+
244
+ prepend(...items: T[]): this {
245
+ if (!this._fed("prepend")) return this
246
+ const [keys, estimates] = this._register(items)
247
+ if (keys.length === 0) return this
248
+ this.keys = keys.concat(this.keys)
249
+ this._ensure()
250
+ if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, 0, keys, estimates)
251
+ return this
252
+ }
253
+
254
+ update(...items: T[]): this {
255
+ if (!this._fed("update")) return this
256
+ for (const item of items) {
257
+ const k = this.keyOf(item)
258
+ if (!this.items.has(k) || this.items.get(k) === item) continue
259
+ this.items.set(k, item)
260
+ if (this._h.id !== 0 && this.mounted.has(k)) tree().vlistInvalidate(this._h.id, k)
261
+ }
262
+ return this
263
+ }
264
+
265
+ removeByKey(...keys: string[]): this {
266
+ if (!this._fed("removeByKey")) return this
267
+ const gone: string[] = []
268
+ for (const k of keys) if (this.items.delete(k)) gone.push(k)
269
+ if (gone.length === 0) return this
270
+ this.keys = this.keys.filter((k) => this.items.has(k))
271
+ if (this._h.id !== 0) tree().vlistRemoveKeys(this._h.id, gone)
272
+ return this
273
+ }
274
+
150
275
  get itemCount(): number { return this.items.size }
151
276
  getItem(key: string): T | undefined { return this.items.get(key) }
152
277
 
@@ -212,8 +337,11 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
212
337
  }
213
338
  }
214
339
 
215
- /** Create a virtualized list from a config (`data` + `keyOf` + `render` + `estimatedHeight`
216
- * required). Give it a size (`flexGrow: 1` as a screen body). */
340
+ /** Create a virtualized list (`keyOf` + `render` + `estimatedHeight` required). With `data` the
341
+ * list follows the signals it reads and has no hand feed; without it, feed it with `setData` /
342
+ * `append` / `prepend` / `update` / `removeByKey`. Give it a size (`flexGrow: 1` as a screen body). */
343
+ export function UIVirtualizedList<T>(config: VListBoundConfig<T>): UIBoundVirtualizedList<T>
344
+ export function UIVirtualizedList<T>(config: VListFedConfig<T>): UIFedVirtualizedList<T>
217
345
  export function UIVirtualizedList<T>(config: VListConfig<T>): UIVirtualizedList<T> {
218
346
  return new VListElement<T>(config)
219
347
  }
@@ -128,7 +128,7 @@ type Side = { lanes: Lane[], atMs: number, durMs: number, easing: EasingSpec }
128
128
  const list = <T>(v: T | T[]): T[] => (Array.isArray(v) ? v : [v])
129
129
 
130
130
  const transformLane = (value: string | string[]): Lane | null => {
131
- const parsed = list(value).map((v) => parseTransformList(v, true))
131
+ const parsed = list(value).map((v) => parseTransformList(v))
132
132
  const first = parsed[0]
133
133
  if (!first || parsed.some((p) => p === null || p.sig !== first.sig)) {
134
134
  warnOnce("transition:transform", "a transition's transform must be a list of translate / scale / rotate, the same functions in every keyframe — ignored")
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.2.3"
7
+ export const SDK_VERSION = "2.2.4"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {