lecodes-sdk 2.2.3 → 2.2.5
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 +3 -1
- package/dist/types/animate/tween/spec.d.ts +4 -5
- package/dist/types/g2/Scene2D.d.ts +5 -4
- package/dist/types/gl/Scene.d.ts +5 -4
- package/dist/types/inject.d.ts +1 -1
- 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/NativeView.d.ts +3 -3
- package/dist/types/ui/UI.d.ts +1 -1
- package/dist/types/ui/UILayer.d.ts +25 -18
- package/dist/types/ui/UINode.d.ts +8 -1
- package/dist/types/ui/UIScreen.d.ts +11 -1
- package/dist/types/ui/UIVideo.d.ts +3 -3
- package/dist/types/ui/UIVirtualizedList.d.ts +64 -18
- package/dist/types/ui/UIWidget.d.ts +24 -15
- package/dist/types/ui/presentable.d.ts +7 -7
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/prompts/3d-scene-files.md +2 -2
- package/prompts/3d-scene.md +1 -1
- package/prompts/dist/2d-game.md +13 -13
- package/prompts/dist/3d-app.md +21 -18
- package/prompts/dist/ar-app.md +13 -13
- package/prompts/dist/ui-app.md +13 -13
- package/prompts/ui.md +3 -1
- package/src/animate/tween/spec.ts +8 -13
- package/src/chisel.ts +3 -3
- package/src/compile/serverSplit.ts +5 -3
- package/src/g2/Scene2D.ts +9 -7
- package/src/gl/Scene.ts +9 -7
- package/src/inject.ts +1 -1
- 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/NativeView.ts +11 -9
- package/src/ui/UI.ts +1 -1
- package/src/ui/UILayer.ts +90 -42
- package/src/ui/UINode.ts +33 -11
- package/src/ui/UIPopover.ts +3 -3
- package/src/ui/UIScreen.ts +50 -2
- package/src/ui/UIVideo.ts +11 -9
- package/src/ui/UIVirtualizedList.ts +159 -31
- package/src/ui/UIWidget.ts +41 -18
- package/src/ui/presentable.ts +8 -8
- package/src/ui/transitions.ts +1 -1
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +6 -1
package/src/inject.ts
CHANGED
|
@@ -104,7 +104,7 @@ export {
|
|
|
104
104
|
UITabs, type UITabDef,
|
|
105
105
|
defineTabs, type TabDef, type TabsHandle,
|
|
106
106
|
UIWidget, type UIWidgetStyle,
|
|
107
|
-
|
|
107
|
+
type WidgetSet, type UIWidgetContent, type UIWidgetChild,
|
|
108
108
|
UIModal, type UIModalStyle,
|
|
109
109
|
UIBottomSheet, type UIBottomSheetStyle,
|
|
110
110
|
UIPopover, type UIPopoverStyle,
|
|
@@ -145,10 +145,10 @@ const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): st
|
|
|
145
145
|
if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
|
|
146
146
|
if (d.optional) type += "?"
|
|
147
147
|
if (d.isId) attrs.unshift("@id")
|
|
148
|
-
if (d.unique) attrs.push("@unique")
|
|
148
|
+
if (d.unique === true) attrs.push("@unique")
|
|
149
|
+
else if (typeof d.unique === "string") attrs.push(`@unique(${d.unique})`)
|
|
149
150
|
if (d.index) attrs.push("@index")
|
|
150
|
-
|
|
151
|
-
if (d.hasDefault && !d.array) attrs.push(`@default(${literal(d.default, d)})`)
|
|
151
|
+
if (d.hasDefault) attrs.push(`@default(${literal(d.default, d)})`)
|
|
152
152
|
if (d.onDelete) attrs.push(`@onDelete(${ON_DELETE[d.onDelete]})`)
|
|
153
153
|
if (d.fulltext) attrs.push(`@fulltext(${d.fulltext})`)
|
|
154
154
|
if (d.vector) attrs.push(`@vector(${d.vector.metric})`)
|
|
@@ -228,6 +228,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
228
228
|
buildMeta({ ...own, [AUTH_SESSION_MODEL]: model({}) })
|
|
229
229
|
|
|
230
230
|
let authModel: string | null = null
|
|
231
|
+
let seedFn: ((db: unknown) => Promise<void>) | null = null
|
|
231
232
|
let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null, files: FileField[] } | null = null
|
|
232
233
|
|
|
233
234
|
const transport = () => {
|
|
@@ -272,20 +273,6 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
272
273
|
}
|
|
273
274
|
}
|
|
274
275
|
const metas = buildMeta(models)
|
|
275
|
-
// the defaults the schema does not carry: an array's (a copy per row)
|
|
276
|
-
const arrayDefaults: Record<string, [string, unknown[]][]> = {}
|
|
277
|
-
for (const meta of Object.values(metas)) {
|
|
278
|
-
arrayDefaults[meta.name] = Object.entries(meta.fields)
|
|
279
|
-
.filter(([, d]) => d.array && d.hasDefault && Array.isArray(d.default))
|
|
280
|
-
.map(([key, d]) => [key, d.default as unknown[]])
|
|
281
|
-
}
|
|
282
|
-
const withDefaults = (name: string, data: any) => {
|
|
283
|
-
const missing = arrayDefaults[name].filter(([key]) => data?.[key] === undefined)
|
|
284
|
-
if (!missing.length) return data
|
|
285
|
-
const out = { ...data }
|
|
286
|
-
for (const [key, value] of missing) out[key] = [...value]
|
|
287
|
-
return out
|
|
288
|
-
}
|
|
289
276
|
const isFile = (name: string, key: string) => fileList.some(f => f.model === name && f.key === key)
|
|
290
277
|
const meta = modelsMeta(metas, isFile)
|
|
291
278
|
if (fileList.length) {
|
|
@@ -302,10 +289,9 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
302
289
|
models: meta,
|
|
303
290
|
run: exec,
|
|
304
291
|
// a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
|
|
305
|
-
prepareInsert: (name,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
},
|
|
292
|
+
prepareInsert: (name, data) => metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data,
|
|
293
|
+
// an upsert that creates the row needs the id a uuid model leaves to the client; the key wins when it is the id
|
|
294
|
+
prepareUpsert: (name, onInsert) => metas[name].uuidId ? { id: crypto.randomUUID(), ...(onInsert ?? {}) } : onInsert,
|
|
309
295
|
})
|
|
310
296
|
const collections: Record<string, unknown> = {}
|
|
311
297
|
for (const name of Object.keys(own)) collections[name] = collection(name)
|
|
@@ -335,8 +321,18 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
335
321
|
db.auth = createAuthApi(() => seal().auth!)
|
|
336
322
|
return db
|
|
337
323
|
},
|
|
324
|
+
// The first rows, written by the host on an empty database (runtime.ts runSeed). Kept as given: a
|
|
325
|
+
// seed may be declared before or after .withAuth, and whether the database is empty is the host's
|
|
326
|
+
// question at the time it opens.
|
|
327
|
+
seed: (fn: (db: unknown) => Promise<void>) => {
|
|
328
|
+
if (typeof fn !== "function") throw new Error("defineDb(...).seed(fn): fn is the async function that writes the first rows")
|
|
329
|
+
if (seedFn) throw new Error("defineDb(...).seed(): called twice on one db — one seed writes every table")
|
|
330
|
+
seedFn = fn
|
|
331
|
+
return db
|
|
332
|
+
},
|
|
338
333
|
}
|
|
339
334
|
Object.defineProperties(db, {
|
|
335
|
+
$seed: { get: () => seedFn },
|
|
340
336
|
$schema: { get: () => seal().schema },
|
|
341
337
|
$models: { get: () => seal().models },
|
|
342
338
|
$given: { get: () => own },
|
package/src/server/db/fields.ts
CHANGED
|
@@ -34,7 +34,8 @@ export type FieldDef = {
|
|
|
34
34
|
kind: FieldKind
|
|
35
35
|
scalar?: ScalarKind
|
|
36
36
|
optional: boolean
|
|
37
|
-
unique
|
|
37
|
+
/** `true` = `@unique`; a string = `@unique(<group>)`: the fields of a model sharing the group are unique together. */
|
|
38
|
+
unique: boolean | string
|
|
38
39
|
index: boolean
|
|
39
40
|
hasDefault: boolean
|
|
40
41
|
default?: unknown
|
|
@@ -64,8 +65,10 @@ export interface Field<T, M extends FieldMeta = FieldMeta> {
|
|
|
64
65
|
readonly def: FieldDef
|
|
65
66
|
/** Nullable column (`String?`). */
|
|
66
67
|
optional(): Field<T, SetMeta<M, "optional", true>>
|
|
67
|
-
/** `@unique` (on a relation: one-to-one).
|
|
68
|
-
unique():
|
|
68
|
+
/** `@unique` (on a relation: one-to-one). With a group name, the fields of the model that share it are
|
|
69
|
+
* unique TOGETHER — `user: t.one("User").unique("pair"), post: t.one("Post").unique("pair")` is one
|
|
70
|
+
* like per user and post; a null part is not constrained. */
|
|
71
|
+
unique(group?: string): Field<T, M>
|
|
69
72
|
/** `@index` — used by `$where`, `$order`, aggregations. */
|
|
70
73
|
index(): Field<T, M>
|
|
71
74
|
/** `@default(...)`; `"now"` on `t.date()` → `now()`. Makes the field optional on insert. */
|
|
@@ -87,7 +90,7 @@ const create = <T, M extends FieldMeta>(def: FieldDef): Field<T, M> => {
|
|
|
87
90
|
return {
|
|
88
91
|
def,
|
|
89
92
|
optional: () => next({ optional: true }),
|
|
90
|
-
unique: () => next({ unique: true }),
|
|
93
|
+
unique: (group?: string) => next({ unique: group ?? true }),
|
|
91
94
|
index: () => next({ index: true }),
|
|
92
95
|
default: (value: unknown) => next({ hasDefault: true, default: value }),
|
|
93
96
|
array: () => next({ array: true }),
|
|
@@ -11,16 +11,31 @@
|
|
|
11
11
|
|
|
12
12
|
import type { MarciOp, MarciTransport } from "./marci/query"
|
|
13
13
|
|
|
14
|
-
/** A failed marcidb request: `status` is the HTTP status (0 = network failure), `
|
|
14
|
+
/** A failed marcidb request: `status` is the HTTP status (0 = network failure), `kind` the server's class of the
|
|
15
|
+
* failure — a constraint (`unique`, `foreign_key`, `restrict`, `duplicate_id`), `not_found`, `bad_request`,
|
|
16
|
+
* `unauthorized`, `internal` —, `field` the `Model.field` a constraint is about, the message its text. */
|
|
15
17
|
export class MarciHttpError extends Error {
|
|
16
18
|
readonly status: number
|
|
17
|
-
|
|
19
|
+
readonly kind: string
|
|
20
|
+
readonly field: string | null
|
|
21
|
+
constructor(status: number, message: string, kind?: string, field: string | null = null) {
|
|
18
22
|
super(message)
|
|
19
23
|
this.name = "MarciHttpError"
|
|
20
24
|
this.status = status
|
|
25
|
+
this.kind = kind ?? (status === 404 ? "not_found" : status === 401 ? "unauthorized" : status >= 500 || status === 0 ? "internal" : "bad_request")
|
|
26
|
+
this.field = field
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/** The error of a failed response: the server's JSON body (`error`, `kind`, `field`), or its text. */
|
|
31
|
+
const failure = (status: number, text: string): MarciHttpError => {
|
|
32
|
+
try {
|
|
33
|
+
const body = JSON.parse(text)
|
|
34
|
+
if (body && typeof body.error === "string") return new MarciHttpError(status, body.error, body.kind, body.field ?? null)
|
|
35
|
+
} catch { /* plain text */ }
|
|
36
|
+
return new MarciHttpError(status, text || `HTTP ${status}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export type MarciHttpOptions = {
|
|
25
40
|
token?: string | null
|
|
26
41
|
fetch?: typeof fetch
|
|
@@ -62,7 +77,7 @@ export const marciRequest = async (
|
|
|
62
77
|
throw new MarciHttpError(0, `marcidb unreachable: ${(e as Error)?.message ?? e}`)
|
|
63
78
|
}
|
|
64
79
|
const text = await res.text()
|
|
65
|
-
if (!res.ok) throw
|
|
80
|
+
if (!res.ok) throw failure(res.status, text)
|
|
66
81
|
if (!text) return undefined
|
|
67
82
|
try { return JSON.parse(text) } catch { return text }
|
|
68
83
|
}
|
|
@@ -77,6 +92,7 @@ export const marciHttpTransport = (baseUrl: string, options: MarciHttpOptions =
|
|
|
77
92
|
case "findFirst": return call(`${op.model}/findFirst`, op.query ?? {})
|
|
78
93
|
case "insert": return call(`${op.model}/insert`, op.data)
|
|
79
94
|
case "update": return call(`${op.model}/update/${encodeMarciId(op.id)}`, op.data).then(() => null)
|
|
95
|
+
case "upsert": return call(`${op.model}/upsert`, { key: op.key, data: op.data, ...(op.onInsert !== undefined ? { onInsert: op.onInsert } : {}) })
|
|
80
96
|
// no id in the path — the filter and the payload travel together in the body
|
|
81
97
|
case "updateMany": return call(`${op.model}/updateMany`, { ...(op.query ?? {}), data: op.data })
|
|
82
98
|
case "delete": return call(`${op.model}/delete/${encodeMarciId(op.id)}`).then(() => null)
|
|
@@ -177,7 +177,7 @@ export type Op<T> = PromiseLike<T> & { readonly [__op]: T }
|
|
|
177
177
|
|
|
178
178
|
// A transport-neutral operation descriptor and the pluggable transport that runs it. The HTTP transport
|
|
179
179
|
// is selected by passing a URL string; marcidb-embedded provides an in-process FFI transport.
|
|
180
|
-
export type MarciOp = { model: string, action: string, query?: any, data?: any, id?: any, journal?: JournalArgs }
|
|
180
|
+
export type MarciOp = { model: string, action: string, query?: any, data?: any, id?: any, key?: any, onInsert?: any, journal?: JournalArgs }
|
|
181
181
|
/** The arguments of a journal action (`$journalOpen` / `$journalRead` / `$journalDrop`). `wait` is in seconds. */
|
|
182
182
|
export type JournalArgs = { name: string, on?: readonly JournalOp[], after?: number, limit?: number, wait?: number }
|
|
183
183
|
export type MarciTransport = {
|
|
@@ -235,9 +235,11 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
|
|
|
235
235
|
readonly [__sel]: Effective<T, Sel>
|
|
236
236
|
/** Filter (marcidb `$where`). Repeated calls are ANDed. */
|
|
237
237
|
where(where: T["where"]): Query<T, Sel>
|
|
238
|
-
/** Sort
|
|
238
|
+
/** Sort: `order("age", "desc")` or `order({ age: "desc" })`. A second `order()` adds the next key — ties on
|
|
239
|
+
* the first are broken by it (`order("createdAt", "desc").order("id")`); the keys may also come as an array
|
|
240
|
+
* in one call. Several keys sort in memory. */
|
|
239
241
|
order(field: keyof T["order"] & string, direction?: "asc" | "desc"): Query<T, Sel>
|
|
240
|
-
order(order: T["order"]): Query<T, Sel>
|
|
242
|
+
order(order: T["order"] | T["order"][]): Query<T, Sel>
|
|
241
243
|
limit(n: number): Query<T, Sel>
|
|
242
244
|
skip(n: number): Query<T, Sel>
|
|
243
245
|
/** Keyset cursor: rows strictly after this id in the current order (`$cursor`). */
|
|
@@ -261,6 +263,13 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
|
|
|
261
263
|
/** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
|
|
262
264
|
insert<S extends T["query"]>(data: T["insert"], options: { select: S }): Promise<Rows<T, S>>
|
|
263
265
|
update(id: IdArg<T["id"]>, data: T["update"]): Op<void>
|
|
266
|
+
/** Writes the row `key` names — the id, or the fields of ONE unique index (a `@unique` field, all the fields of
|
|
267
|
+
* a `@unique(<name>)` group): an update of `data` when it exists, an insert of key + data when not. `data` is
|
|
268
|
+
* in the insert form (plain values, `{ id }` for a relation; no `$` operators). `onInsert` adds fields used
|
|
269
|
+
* only when the row is created. Resolves to the id, like `insert`. */
|
|
270
|
+
upsert<K extends Partial<T["insert"]> | T["id"]>(key: K, data: Omit<T["insert"], keyof K>, options?: { onInsert?: Partial<T["insert"]> }): Op<T["id"]>
|
|
271
|
+
/** The row written, by `select`'s shape — as stored, whichever path the upsert took. */
|
|
272
|
+
upsert<K extends Partial<T["insert"]> | T["id"], S extends T["query"]>(key: K, data: Omit<T["insert"], keyof K>, options: { onInsert?: Partial<T["insert"]>, select: S }): Promise<Rows<T, S>>
|
|
264
273
|
/** Applies `data` to every row the chain's `where` matches (all rows without one); resolves to the number of rows. */
|
|
265
274
|
updateMany(data: T["update"]): Op<number>
|
|
266
275
|
/** @deprecated pass the filter through the chain: `db.user.where(w).updateMany(data)` (removed in the next minor). */
|
|
@@ -322,6 +331,9 @@ export type QueryLayerOptions = {
|
|
|
322
331
|
run(op: MarciOp): Promise<any>;
|
|
323
332
|
/** Hook for `insert` payloads (the lecodes SDK generates uuid ids here). */
|
|
324
333
|
prepareInsert?(model: string, data: any): any;
|
|
334
|
+
/** Hook for the `onInsert` part of an `upsert` — the fields used only when the row is created (the lecodes SDK
|
|
335
|
+
* puts a uuid id here). Receives what the caller gave (or undefined), returns what is sent. */
|
|
336
|
+
prepareUpsert?(model: string, onInsert: any): any;
|
|
325
337
|
};
|
|
326
338
|
|
|
327
339
|
const AGGREGATE_KEYS = ["$count", "$sum", "$avg", "$min", "$max"];
|
|
@@ -432,6 +444,10 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
|
|
|
432
444
|
return w ? { $where: w } : {};
|
|
433
445
|
};
|
|
434
446
|
const findMany = (): MarciOp => ({ model, action: "findMany", query: build() });
|
|
447
|
+
// the row as stored, read back by the id the engine answered — a plain promise: a read is not a $transaction member
|
|
448
|
+
const readBack = (written: Op<any>, select: Record<string, any>) =>
|
|
449
|
+
written.then((id: any) => run({ model, action: "findFirst", query: resolveShape(model, { ...select, $where: id }) }))
|
|
450
|
+
.then((row: any) => { if (row === null) throw new Error(`marcidb: ${model}: the row just written is gone`); return row; });
|
|
435
451
|
return {
|
|
436
452
|
get __op() { return findMany(); },
|
|
437
453
|
get __select() { return build(); },
|
|
@@ -440,7 +456,15 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
|
|
|
440
456
|
finally: (onFinally?: any) => run(findMany()).finally(onFinally),
|
|
441
457
|
|
|
442
458
|
where: (where: any) => make({ ...st, where: and(st.where, where) }),
|
|
443
|
-
order: (field: any, direction?: "asc" | "desc") =>
|
|
459
|
+
order: (field: any, direction?: "asc" | "desc") => {
|
|
460
|
+
// one key per entry; an object with several keys is read in its key order
|
|
461
|
+
const given: any[] = typeof field === "string" ? [{ [field]: direction ?? "asc" }]
|
|
462
|
+
: Array.isArray(field) ? field
|
|
463
|
+
: Object.entries(field).map(([k, v]) => ({ [k]: v }));
|
|
464
|
+
const prev: any[] = st.order === undefined ? [] : Array.isArray(st.order) ? st.order : [st.order];
|
|
465
|
+
const keys = [...prev, ...given];
|
|
466
|
+
return make({ ...st, order: keys.length === 1 ? keys[0] : keys });
|
|
467
|
+
},
|
|
444
468
|
limit: (n: number) => make({ ...st, limit: n }),
|
|
445
469
|
skip: (n: number) => make({ ...st, skip: n }),
|
|
446
470
|
after: (id: any) => make({ ...st, cursor: idObject(model, id) }),
|
|
@@ -458,12 +482,14 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
|
|
|
458
482
|
|
|
459
483
|
insert: (data: any, extra?: { select?: Record<string, any> }) => {
|
|
460
484
|
const inserted = op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data });
|
|
461
|
-
|
|
462
|
-
// the row as stored, read back by the id the engine answered — a plain promise: a read is not a $transaction member
|
|
463
|
-
return inserted.then((id: any) => run({ model, action: "findFirst", query: resolveShape(model, { ...extra.select, $where: id }) }))
|
|
464
|
-
.then((row: any) => { if (row === null) throw new Error(`marcidb: ${model}: the row just inserted is gone`); return row; });
|
|
485
|
+
return extra?.select ? readBack(inserted, extra.select) : inserted;
|
|
465
486
|
},
|
|
466
487
|
update: (id: any, data: any) => op({ model, action: "update", id: idObject(model, id), data }),
|
|
488
|
+
upsert: (key: any, data: any, o?: { onInsert?: any, select?: Record<string, any> }) => {
|
|
489
|
+
const onInsert = options.prepareUpsert ? options.prepareUpsert(model, o?.onInsert) : o?.onInsert;
|
|
490
|
+
const written = op({ model, action: "upsert", key, data, ...(onInsert !== undefined ? { onInsert } : {}) });
|
|
491
|
+
return o?.select ? readBack(written, o.select) : written;
|
|
492
|
+
},
|
|
467
493
|
// `updateMany(data)`; the deprecated `updateMany(query, data)` form is told apart by arity
|
|
468
494
|
updateMany: (a: any, b?: any) => (b === undefined
|
|
469
495
|
? op({ model, action: "updateMany", query: whereOnly(), data: a })
|
package/src/server/db/types.ts
CHANGED
|
@@ -68,11 +68,11 @@ export type ScalarSelect<S, N extends keyof S> = { [K in (ScalarKeys<FieldsOf<S[
|
|
|
68
68
|
|
|
69
69
|
// ───────────────────────────── insert ─────────────────────────────
|
|
70
70
|
|
|
71
|
-
type FieldInsert<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
|
|
71
|
+
type FieldInsert<S, Fld, FW, M extends FieldMeta = MetaOf<Fld>> =
|
|
72
72
|
M["kind"] extends "one" ? Nullable<M, Id<S, ModelName<S, Fld>>>
|
|
73
73
|
: M["kind"] extends "many" | "list" ? Id<S, ModelName<S, Fld>>[]
|
|
74
|
-
: M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld
|
|
75
|
-
: M["kind"] extends "file" ? Nullable<M, Arr<M,
|
|
74
|
+
: M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld>, FW>>
|
|
75
|
+
: M["kind"] extends "file" ? Nullable<M, Arr<M, FW>> // a new row takes uploads (a seed: a resource of the project too)
|
|
76
76
|
: Nullable<M, Arr<M, TsOf<Fld>>>
|
|
77
77
|
|
|
78
78
|
type RequiredKeys<F extends Fields> = {
|
|
@@ -84,12 +84,13 @@ type RequiredKeys<F extends Fields> = {
|
|
|
84
84
|
: K
|
|
85
85
|
}[keyof F]
|
|
86
86
|
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
87
|
-
type InsertOf<S, F extends Fields> = Simplify<
|
|
88
|
-
{ [K in RequiredKeys<F>]: FieldInsert<S, F[K]> } &
|
|
89
|
-
{ [K in Exclude<keyof F, RequiredKeys<F>>]?: FieldInsert<S, F[K]> }
|
|
87
|
+
type InsertOf<S, F extends Fields, FW = FileWrite> = Simplify<
|
|
88
|
+
{ [K in RequiredKeys<F>]: FieldInsert<S, F[K], FW> } &
|
|
89
|
+
{ [K in Exclude<keyof F, RequiredKeys<F>>]?: FieldInsert<S, F[K], FW> }
|
|
90
90
|
>
|
|
91
|
-
|
|
92
|
-
type
|
|
91
|
+
/** `FW` is what a file field is written with: an upload of the call; in a seed, a resource of the project as well. */
|
|
92
|
+
export type Insert<S, N extends keyof S, FW = FileWrite> = InsertOf<S, FieldsOf<S[N]>, FW>
|
|
93
|
+
type StructInsert<S, F extends Fields, FW = FileWrite> = InsertOf<S, F, FW>
|
|
93
94
|
|
|
94
95
|
// ───────────────────────────── update ─────────────────────────────
|
|
95
96
|
|
|
@@ -138,7 +139,8 @@ export type Where<S, N extends keyof S> = WhereValue<WhereFieldsOf<S, FieldsOf<S
|
|
|
138
139
|
type StructWhereFields<S, F extends Fields> = WhereFieldsOf<S, F>
|
|
139
140
|
|
|
140
141
|
type OrderKeys<S, N extends keyof S> = (ValueKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>
|
|
141
|
-
/**
|
|
142
|
+
/** One field and its direction. Several keys are several of these, in an array (`$order: [{ createdAt: "desc" },
|
|
143
|
+
* { id: "asc" }]`) or chained `.order()` calls — an object with two keys is refused, since JSON promises no key order. */
|
|
142
144
|
export type Order<S, N extends keyof S> = {
|
|
143
145
|
[K in OrderKeys<S, N>]: { [P in K]: "asc" | "desc" } & { [P in Exclude<OrderKeys<S, N>, K>]?: never }
|
|
144
146
|
}[OrderKeys<S, N>]
|
|
@@ -146,7 +148,7 @@ export type Order<S, N extends keyof S> = {
|
|
|
146
148
|
/** The query object — marcidb's wire form: a select shape plus the `$`-clauses. */
|
|
147
149
|
export type QueryObject<S, N extends keyof S> = Select<S, N> & {
|
|
148
150
|
$where?: Where<S, N>
|
|
149
|
-
$order?: Order<S, N>
|
|
151
|
+
$order?: Order<S, N> | Order<S, N>[]
|
|
150
152
|
$limit?: number
|
|
151
153
|
$skip?: number
|
|
152
154
|
$cursor?: Id<S, N>
|
|
@@ -164,7 +166,7 @@ export type AggregateQuery<S, N extends keyof S> = {
|
|
|
164
166
|
// ───────────────────────────── query / db ─────────────────────────────
|
|
165
167
|
|
|
166
168
|
/** The per-model type bag marcidb's generic `Query<T>` is parametrised by (what its codegen emits as `UserTypes`). */
|
|
167
|
-
export type Types<S, N extends keyof S> = {
|
|
169
|
+
export type Types<S, N extends keyof S, FW = FileWrite> = {
|
|
168
170
|
name: N & string
|
|
169
171
|
model: Row<S, N>
|
|
170
172
|
id: Id<S, N>
|
|
@@ -173,7 +175,7 @@ export type Types<S, N extends keyof S> = {
|
|
|
173
175
|
query: QueryObject<S, N>
|
|
174
176
|
where: Where<S, N>
|
|
175
177
|
order: Order<S, N>
|
|
176
|
-
insert: Insert<S, N>
|
|
178
|
+
insert: Insert<S, N, FW>
|
|
177
179
|
update: Update<S, N>
|
|
178
180
|
aggregate: AggregateQuery<S, N>
|
|
179
181
|
reindex: false
|
|
@@ -185,7 +187,7 @@ export type Types<S, N extends keyof S> = {
|
|
|
185
187
|
* `updateMany(data)`/`deleteMany()` write through the chain's `where`; a query placed in another query's
|
|
186
188
|
* select is a sub-query. `Sel` is the projection: id + scalars until `select(...)` sets a shape.
|
|
187
189
|
*/
|
|
188
|
-
export type Query<S, N extends keyof S, Sel = ScalarSelect<S, N
|
|
190
|
+
export type Query<S, N extends keyof S, Sel = ScalarSelect<S, N>, FW = FileWrite> = MarciQuery<Types<S, N, FW>, Sel>
|
|
189
191
|
/** The rows a query with projection `Q` resolves to (no field keys = id + scalars; sub-queries resolved). */
|
|
190
192
|
export type Result<S, N extends keyof S, Q> = Rows<Types<S, N>, Q>
|
|
191
193
|
/** `db.<model>` — the root query of a model. */
|
|
@@ -212,8 +214,22 @@ type DbBase<S extends Schema> = {
|
|
|
212
214
|
readonly $auth: AuthBinding | null
|
|
213
215
|
/** @internal the schema's `t.file()` fields (../files/models.ts). */
|
|
214
216
|
readonly $files: FileField[]
|
|
217
|
+
/** @internal the seed, when the definition has one (`.seed(fn)`); the host runs it on an empty database. */
|
|
218
|
+
readonly $seed: ((db: SeedDb<S>) => Promise<void>) | null
|
|
219
|
+
/**
|
|
220
|
+
* The first rows of this database: run by the host ONCE, on a database that is empty, wherever the
|
|
221
|
+
* database opens (a check, a dev backend, the first deploy). Part of the definition, like sign-in:
|
|
222
|
+
* written on `defineDb(...)` itself. Inside, a file field takes a resource of the project — the path
|
|
223
|
+
* of a picture the design shows (`photo: row.photo`) — and stores it the way an upload is stored.
|
|
224
|
+
*/
|
|
225
|
+
seed(fn: (db: SeedDb<S>) => Promise<void>): Db<S>
|
|
215
226
|
}
|
|
216
227
|
|
|
228
|
+
/** The db a seed writes through: the same queries, a file field taking a resource of the project (its path) too. */
|
|
229
|
+
export type SeedDb<S extends Schema> = {
|
|
230
|
+
[N in keyof S & string as Uncapitalize<N>]: Query<Full<S>, N, ScalarSelect<Full<S>, N>, FileWrite | string>
|
|
231
|
+
} & Pick<DbBase<S>, "$transaction">
|
|
232
|
+
|
|
217
233
|
export type Db<S extends Schema> = DbBase<S> & {
|
|
218
234
|
/**
|
|
219
235
|
* Sign-in for this database: `model` is the project's model of a user. Adds the platform's `Session`
|
|
@@ -223,7 +239,10 @@ export type Db<S extends Schema> = DbBase<S> & {
|
|
|
223
239
|
*/
|
|
224
240
|
withAuth<N extends keyof S & string>(options: { model: N }): DbWithAuth<S, N>
|
|
225
241
|
}
|
|
226
|
-
export type DbWithAuth<S extends Schema, N extends keyof S & string> = DbBase<S> & {
|
|
242
|
+
export type DbWithAuth<S extends Schema, N extends keyof S & string> = Omit<DbBase<S>, "seed"> & {
|
|
243
|
+
readonly auth: Auth<S, N>
|
|
244
|
+
seed(fn: (db: SeedDb<S>) => Promise<void>): DbWithAuth<S, N>
|
|
245
|
+
}
|
|
227
246
|
|
|
228
247
|
/** @internal */
|
|
229
248
|
export type AuthBinding = { model: string, user: any, session: any, identity: any }
|
package/src/server/errors.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `throw new ApiError(404, "Not found")` inside an endpoint → the client's promise rejects with the
|
|
3
|
-
* same status + message.
|
|
3
|
+
* same status + message. A write the database refused for a reason a person could have caused reaches
|
|
4
|
+
* the app the same way, in the database's words: a taken unique value, a pointer at no row, a delete
|
|
5
|
+
* other rows forbid are 409, a row that is gone 404. Any other throw is a 500 (message hidden, logged
|
|
6
|
+
* to the server console).
|
|
4
7
|
*/
|
|
5
8
|
export class ApiError extends Error {
|
|
6
9
|
readonly status: number
|
package/src/server/files/db.ts
CHANGED
|
@@ -108,19 +108,38 @@ export const createFileLayer = (
|
|
|
108
108
|
return { data: rest, files }
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
const attach = async (field: FileField, id: string, owner: unknown, position: number): Promise<MarciOp> => {
|
|
112
|
-
|
|
111
|
+
const attach = async (field: FileField, id: string, owner: unknown, position: number, value?: unknown): Promise<MarciOp> => {
|
|
112
|
+
let facts = {}
|
|
113
|
+
if (field.image) {
|
|
114
|
+
try { facts = await filesSeam()!.image(id, field.image) } catch (e) {
|
|
115
|
+
// a resource of the project (a seed): the field and the path name what to fix
|
|
116
|
+
if (typeof value === "string") throw new Error(`${field.model}.${field.key}: "${value}": ${e instanceof Error ? e.message : e}`)
|
|
117
|
+
throw e
|
|
118
|
+
}
|
|
119
|
+
}
|
|
113
120
|
return { model: FILE_MODEL, action: "update", id: { id }, data: { ...facts, [field.owner]: { $connect: owner }, position } }
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
/** An upload of the current request — or, while a seed runs, a resource of the project named by its path. */
|
|
124
|
+
const uploadOf = async (value: unknown): Promise<string | undefined> => {
|
|
125
|
+
const seam = filesSeam()
|
|
126
|
+
const id = seam?.uploaded(value)
|
|
127
|
+
if (id) return id
|
|
128
|
+
return typeof value === "string" && seam?.resource ? seam.resource(value) : undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
const insert = async (op: MarciOp, index: number): Promise<Plan> => {
|
|
117
132
|
const { data, files } = split(op.model, op.data)
|
|
118
133
|
const after: MarciOp[] = []
|
|
119
134
|
for (const [field, values] of files) {
|
|
120
135
|
for (let position = 0; position < values.length; position++) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
const value = values[position]
|
|
137
|
+
const id = await uploadOf(value)
|
|
138
|
+
if (!id) {
|
|
139
|
+
if (typeof value === "string" && filesSeam()?.resource) throw new Error(`${op.model}.${field.key}: "${value}" is not a file of the project`)
|
|
140
|
+
throw new Error(`${op.model}.${field.key}: a new row takes uploaded files only — a file that is already stored belongs to its row`)
|
|
141
|
+
}
|
|
142
|
+
after.push(await attach(field, id, { $ref: String(index) }, position, value))
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
145
|
return { op: { ...op, data }, after }
|
|
@@ -134,11 +153,11 @@ export const createFileLayer = (
|
|
|
134
153
|
const then: MarciOp[] = []
|
|
135
154
|
for (let position = 0; position < values.length; position++) {
|
|
136
155
|
const value = values[position]
|
|
137
|
-
const upload =
|
|
156
|
+
const upload = await uploadOf(value)
|
|
138
157
|
const id = upload ?? storedFileId(value)
|
|
139
158
|
if (!id) throw new Error(`${op.model}.${field.key}: a file is expected — an upload, or the stored file the field already holds`)
|
|
140
159
|
keep.push(id)
|
|
141
|
-
if (upload) then.push(await attach(field, upload, op.id, position))
|
|
160
|
+
if (upload) then.push(await attach(field, upload, op.id, position, value))
|
|
142
161
|
// one that stays: its place in the list — and only where it IS this row's file
|
|
143
162
|
else if (field.array) then.push({ model: FILE_MODEL, action: "updateMany", query: { $where: { id, [field.owner]: op.id } }, data: { position } })
|
|
144
163
|
}
|
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 })
|