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.
Files changed (61) hide show
  1. package/dist/global.d.ts +3 -1
  2. package/dist/types/animate/tween/spec.d.ts +4 -5
  3. package/dist/types/g2/Scene2D.d.ts +5 -4
  4. package/dist/types/gl/Scene.d.ts +5 -4
  5. package/dist/types/inject.d.ts +1 -1
  6. package/dist/types/server/db/fields.d.ts +6 -3
  7. package/dist/types/server/db/marci/query.d.ts +21 -2
  8. package/dist/types/server/db/types.d.ts +26 -12
  9. package/dist/types/server/errors.d.ts +4 -1
  10. package/dist/types/server/files/models.d.ts +4 -0
  11. package/dist/types/server/inject.d.ts +6 -0
  12. package/dist/types/ui/NativeView.d.ts +3 -3
  13. package/dist/types/ui/UI.d.ts +1 -1
  14. package/dist/types/ui/UILayer.d.ts +25 -18
  15. package/dist/types/ui/UINode.d.ts +8 -1
  16. package/dist/types/ui/UIScreen.d.ts +11 -1
  17. package/dist/types/ui/UIVideo.d.ts +3 -3
  18. package/dist/types/ui/UIVirtualizedList.d.ts +64 -18
  19. package/dist/types/ui/UIWidget.d.ts +24 -15
  20. package/dist/types/ui/presentable.d.ts +7 -7
  21. package/dist/types/version.d.ts +1 -1
  22. package/dist/types.json +1 -1
  23. package/package.json +2 -2
  24. package/prompts/3d-scene-files.md +2 -2
  25. package/prompts/3d-scene.md +1 -1
  26. package/prompts/dist/2d-game.md +13 -13
  27. package/prompts/dist/3d-app.md +21 -18
  28. package/prompts/dist/ar-app.md +13 -13
  29. package/prompts/dist/ui-app.md +13 -13
  30. package/prompts/ui.md +3 -1
  31. package/src/animate/tween/spec.ts +8 -13
  32. package/src/chisel.ts +3 -3
  33. package/src/compile/serverSplit.ts +5 -3
  34. package/src/g2/Scene2D.ts +9 -7
  35. package/src/gl/Scene.ts +9 -7
  36. package/src/inject.ts +1 -1
  37. package/src/server/db/defineDb.ts +17 -21
  38. package/src/server/db/fields.ts +7 -4
  39. package/src/server/db/httpTransport.ts +19 -3
  40. package/src/server/db/marci/query.ts +34 -8
  41. package/src/server/db/types.ts +33 -14
  42. package/src/server/errors.ts +4 -1
  43. package/src/server/files/db.ts +26 -7
  44. package/src/server/files/host.ts +45 -17
  45. package/src/server/files/models.ts +4 -0
  46. package/src/server/host.ts +3 -3
  47. package/src/server/inject.ts +3 -0
  48. package/src/server/runtime.ts +65 -4
  49. package/src/ui/NativeView.ts +11 -9
  50. package/src/ui/UI.ts +1 -1
  51. package/src/ui/UILayer.ts +90 -42
  52. package/src/ui/UINode.ts +33 -11
  53. package/src/ui/UIPopover.ts +3 -3
  54. package/src/ui/UIScreen.ts +50 -2
  55. package/src/ui/UIVideo.ts +11 -9
  56. package/src/ui/UIVirtualizedList.ts +159 -31
  57. package/src/ui/UIWidget.ts +41 -18
  58. package/src/ui/presentable.ts +8 -8
  59. package/src/ui/transitions.ts +1 -1
  60. package/src/version.ts +1 -1
  61. 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
- UIOverlay, type UIWidgetContent,
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
- // an array's default is filled in on insert (`withDefaults`): the engine reads `@default([])` as [""]
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, raw) => {
306
- const data = withDefaults(name, raw)
307
- return metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data
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 },
@@ -34,7 +34,8 @@ export type FieldDef = {
34
34
  kind: FieldKind
35
35
  scalar?: ScalarKind
36
36
  optional: boolean
37
- unique: boolean
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(): Field<T, M>
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), `message` its body. */
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
- constructor(status: number, message: string) {
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 new MarciHttpError(res.status, text || `HTTP ${res.status}`)
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 by one field: `order("age", "desc")` or `order({ age: "desc" })`. */
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") => make({ ...st, order: typeof field === "string" ? { [field]: direction ?? "asc" } : field }),
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
- if (!extra?.select) return inserted;
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 })
@@ -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, FileWrite>> // a new row takes uploads
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
- export type Insert<S, N extends keyof S> = InsertOf<S, FieldsOf<S[N]>>
92
- type StructInsert<S, F extends Fields> = InsertOf<S, F>
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
- /** ONE field and its direction: the engine sorts by a single field, so a second key is a type error. */
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>> = MarciQuery<Types<S, N>, Sel>
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> & { readonly auth: Auth<S, N> }
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 }
@@ -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. Any other throw is a 500 (message hidden, logged to the server console).
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
@@ -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
- const facts = field.image ? await filesSeam()!.image(id, field.image) : {}
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 id = filesSeam()?.uploaded(values[position])
122
- if (!id) throw new Error(`${op.model}.${field.key}: a new row takes uploaded files only — a file that is already stored belongs to its row`)
123
- after.push(await attach(field, id, { $ref: String(index) }, position))
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 = filesSeam()?.uploaded(value)
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
  }
@@ -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 })