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.
- package/dist/global.d.ts +1 -0
- package/dist/types/animate/tween/spec.d.ts +4 -5
- package/dist/types/server/db/fields.d.ts +6 -3
- package/dist/types/server/db/marci/query.d.ts +21 -2
- package/dist/types/server/db/types.d.ts +26 -12
- package/dist/types/server/errors.d.ts +4 -1
- package/dist/types/server/files/models.d.ts +4 -0
- package/dist/types/server/inject.d.ts +6 -0
- package/dist/types/ui/UINode.d.ts +3 -0
- package/dist/types/ui/UIVirtualizedList.d.ts +64 -18
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/prompts/ui.md +3 -1
- package/src/animate/tween/spec.ts +8 -13
- package/src/chisel.ts +2 -2
- package/src/compile/serverSplit.ts +5 -3
- package/src/server/db/defineDb.ts +17 -21
- package/src/server/db/fields.ts +7 -4
- package/src/server/db/httpTransport.ts +19 -3
- package/src/server/db/marci/query.ts +34 -8
- package/src/server/db/types.ts +33 -14
- package/src/server/errors.ts +4 -1
- package/src/server/files/db.ts +26 -7
- package/src/server/files/host.ts +45 -17
- package/src/server/files/models.ts +4 -0
- package/src/server/host.ts +3 -3
- package/src/server/inject.ts +3 -0
- package/src/server/runtime.ts +65 -4
- package/src/ui/UINode.ts +3 -0
- package/src/ui/UIVirtualizedList.ts +159 -31
- package/src/ui/transitions.ts +1 -1
- package/src/version.ts +1 -1
package/src/server/files/host.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/server/host.ts
CHANGED
|
@@ -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"
|
package/src/server/inject.ts
CHANGED
|
@@ -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 })
|
package/src/server/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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`).
|
|
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
|
|
36
|
-
*
|
|
37
|
-
export interface
|
|
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
|
-
|
|
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(
|
|
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
|
|
108
|
-
*
|
|
109
|
-
|
|
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
|
-
//
|
|
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) {
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
216
|
-
*
|
|
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
|
}
|
package/src/ui/transitions.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 => {
|