lecodes-sdk 2.2.2 → 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/package.json CHANGED
@@ -42,10 +42,10 @@
42
42
  "@types/node": "^25.9.1",
43
43
  "typescript": "~5.8.3",
44
44
  "gl-matrix": "^3.4.4",
45
- "marcidb-embedded": "^0.14.1",
45
+ "marcidb-embedded": "^0.15.0",
46
46
  "sharp": "^0.35.2"
47
47
  },
48
- "version": "2.2.2",
48
+ "version": "2.2.4",
49
49
  "files": [
50
50
  "src",
51
51
  "dist",
package/prompts/ui.md CHANGED
@@ -125,7 +125,9 @@ UIScrollable(...children)
125
125
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
126
126
  // is large, grows over time, or is unknown.
127
127
  UIVirtualizedList<T>({
128
- data: () => T[], // the rows, read reactively: write the signal, the list follows (new array, keys matched)
128
+ data?: () => T[], // the rows, read reactively: write the signal, the list follows (new array, keys matched)
129
+ // absent = fed by hand: .setData(items) / .append(...items) / .prepend(...items) / .update(...items) / .removeByKey(...keys)
130
+ // (one feed per list: a list with `data` has no append on its type)
129
131
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
130
132
  render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
131
133
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
@@ -200,7 +200,7 @@ export const uiValue = (v: unknown): ({ kind: number, sig?: string } & TweenValu
200
200
 
201
201
  /** Per-function argument counts of a UI transform list (degrees / px are the SDK's units). A
202
202
  * translate named with a trailing `%` holds PERCENTS of the target's own box — a screen
203
- * transition's pose (ui/transitions.ts), the only place that writes one. */
203
+ * transition's pose (ui/transitions.ts) and a style's own `translate(-50%, -50%)` alike. */
204
204
  const TRANSFORM_FNS: Record<string, number> = {
205
205
  translate: 2, translateX: 1, translateY: 1, rotate: 1, scale: 2, scaleX: 1, scaleY: 1, skewX: 1, skewY: 1,
206
206
  "translate%": 2, "translateX%": 1, "translateY%": 1,
@@ -210,11 +210,10 @@ const TRANSFORM_FNS: Record<string, number> = {
210
210
  * function is unknown (the caller falls back to matrix decomposition on the host). `scale(2)` and
211
211
  * `translate(5px)` complete their second argument like CSS.
212
212
  *
213
- * `percent`: a `%` in a translate is a percent of the target's box (`translateX(100%)` →
214
- * `translateX%` [100]; a translate mixing the units splits into its two axes). Without it a `%`
215
- * reads as px, as the style's own parser reads it — with a warning: only a screen transition
216
- * measures a translate in percents. */
217
- export const parseTransformList = (s: string, percent = false): { sig: string, lanes: number[] } | null => {
213
+ * A `%` in a translate is a percent of the target's own box (`translateX(100%)` → `translateX%`
214
+ * [100]; a translate mixing the units splits into its two axes), the way the runtime reads a
215
+ * style's transform: the box is measured when the matrix is composed, so a track fits any size. */
216
+ export const parseTransformList = (s: string): { sig: string, lanes: number[] } | null => {
218
217
  const sig: string[] = []
219
218
  const lanes: number[] = []
220
219
  const re = /([a-zA-Z]+)\s*\(([^)]*)\)/g
@@ -236,17 +235,13 @@ export const parseTransformList = (s: string, percent = false): { sig: string, l
236
235
  if (args.length < n) { args.push(fn === "translate" ? 0 : args[0]!); units.push(units[0]!) }
237
236
  consumed = re.lastIndex
238
237
  if (fn.startsWith("translate") && units.includes("%")) {
239
- if (!percent) {
240
- warnOnce("transform:%", `transform: a % in ${fn}() is read as px — percents of the box work in a screen transition only`)
241
- } else if (fn === "translate" && units[0] !== units[1]) {
238
+ if (fn === "translate" && units[0] !== units[1]) {
242
239
  sig.push(units[0] === "%" ? "translateX%" : "translateX", units[1] === "%" ? "translateY%" : "translateY")
243
- lanes.push(...args)
244
- continue
245
240
  } else {
246
241
  sig.push(fn + "%")
247
- lanes.push(...args)
248
- continue
249
242
  }
243
+ lanes.push(...args)
244
+ continue
250
245
  }
251
246
  sig.push(fn)
252
247
  lanes.push(...args)
package/src/chisel.ts CHANGED
@@ -133,8 +133,8 @@ export const chiselVocab: ChiselVocab = {
133
133
  "append", "insert", "remove", "setContent", "keepAlive", "url",
134
134
  "detents", "dismissible", "transition", "setSourceRect",
135
135
  "push", "pop", "popToRoot", "replace", "select",
136
- // UITabs / the native views (NativeView, CameraView, MapView, QRScanner)
137
- "badge", "setData",
136
+ // UITabs / UIVirtualizedList's hand feed / the native views (NativeView, CameraView, MapView, QRScanner)
137
+ "badge", "setData", "update", "prepend", "removeByKey",
138
138
  "on", "off", "onReady", "onError", "onTap", "onMove", "onScan",
139
139
  "flyTo", "jumpTo", "fitPoints", "setPadding", "setUserLocation",
140
140
  ],
@@ -258,13 +258,15 @@ export const compileServerBundle = async (opts: CompileServerOptions): Promise<{
258
258
  // The server bundle sees the whole project map (a server module may import shared code), but the
259
259
  // bundler's asset check runs over EVERY file in the map — so binaries and svgs must be present as
260
260
  // stubs, or a client screen's `asset("./x.png")` fails the SERVER build ("asset not found", seen
261
- // with a Figma-exported design next to app.server.ts). The board's `design/` files are never
262
- // server code and stay out entirely.
261
+ // with a Figma-exported design next to app.server.ts). A stub's value is the resource's PATH: a
262
+ // row of shared data that names a picture (`photo: "images/a.jpg"`) reaches the server as the
263
+ // path of that picture, which a seed stores into a file field (files/db.ts). The board's `design/`
264
+ // files are never server code and stay out entirely.
263
265
  const files: MemoryFiles = {}
264
266
  for (const e of opts.entries) {
265
267
  const path = normalizePath(e.path)
266
268
  if (/^\/design\//.test(path)) continue
267
- if (e.type !== "text" || path.toLowerCase().endsWith(".svg")) { files[path] = `export default ""`; continue }
269
+ if (e.type !== "text" || path.toLowerCase().endsWith(".svg")) { files[path] = `export default ${JSON.stringify(path)}`; continue }
268
270
  files[path] = e.text ?? ""
269
271
  }
270
272
  let params = opts.params
@@ -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
  }