lecodes-sdk 2.0.7 → 2.0.8

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,9 +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.13.0"
45
+ "marcidb-embedded": "^0.14.1",
46
+ "sharp": "^0.35.2"
46
47
  },
47
- "version": "2.0.7",
48
+ "version": "2.0.8",
48
49
  "files": [
49
50
  "src",
50
51
  "dist",
@@ -31,6 +31,15 @@ export const rewriteLibraryImports = (src: string, slugs: Set<string>): string =
31
31
  if (isRelativeOrAbsolute(spec)) return spec
32
32
  const slash = spec.indexOf("/")
33
33
  const slug = slash === -1 ? spec : spec.slice(0, slash)
34
+ // The SDK's plugin bases (`lecodes-sdk/plugin`: ServiceChannel, ViewChannel — what a plugin's
35
+ // generated wrapper extends): the one SDK module a PROJECT file may import, because a plugin of
36
+ // the project itself (`plugins/<id>/sdk/<id>.gen.ts`, written by `lecodes plugin gen`) lives in
37
+ // the project tree and not in the SDK. It is the SDK's own module under /__sdk, the same instance
38
+ // the vendored first-party wrappers reach by relative path. Anything else of the SDK is a global.
39
+ if (slug === "lecodes-sdk") {
40
+ if (spec === "lecodes-sdk/plugin" || spec.startsWith("lecodes-sdk/plugin/")) return `/__sdk${spec.slice("lecodes-sdk".length)}`
41
+ throw new Error(`import from "${spec}": the SDK needs no imports — its API is globals; only "lecodes-sdk/plugin" (a plugin wrapper's bases) is imported.`)
42
+ }
34
43
  if (slugs.has(slug)) {
35
44
  // `acme-ui` → `/__lib/acme-ui/index`; `acme-ui/theme` → `/__lib/acme-ui/theme`.
36
45
  const sub = slash === -1 ? "/index" : spec.slice(slash)
@@ -127,6 +127,8 @@ const wrongSideGlobals = (ts: TS, program: import("typescript").Program, checker
127
127
  return out
128
128
  }
129
129
 
130
+ const FILE_TYPE_PATH = "/__lecodes/file.d.ts"
131
+
130
132
  export type DeriveResult = {
131
133
  /** endpoint id → parameter schemas (only endpoints the checker could see). */
132
134
  params: Record<string, ParamSchema[]>
@@ -152,6 +154,9 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
152
154
  if (!/\.[tj]sx?$/.test(e.path)) continue
153
155
  files.set(normalizePath(e.path), e.text)
154
156
  }
157
+ // The one SDK type this program knows: a parameter typed `File` is an upload, and has to be told
158
+ // from "some object". A declaration of the type alone — as a value the name stays unknown here.
159
+ files.set(FILE_TYPE_PATH, "interface File { readonly __lecodesFile: true, readonly name: string, readonly size: number, readonly type: string }\n")
155
160
  // the server modules, and every other file of the project: the app's are read for the globals they use
156
161
  const roots = [...new Set([...Object.keys(modules).map(normalizePath).filter(p => files.has(p)), ...files.keys()])]
157
162
 
@@ -346,6 +351,7 @@ const lower = (ts: TS, checker: import("typescript").TypeChecker, type: import("
346
351
  }
347
352
  if (f & T.Union) return lowerMembers(ts, checker, (type as import("typescript").UnionType).types, where, stack)
348
353
  if (f & T.Intersection || f & T.Object) {
354
+ if (type.getProperty("__lecodesFile")) return { t: "file" }
349
355
  if (stack.has(type)) throw new Error(`${where}: recursive type ${checker.typeToString(type)}`)
350
356
  if (type.getCallSignatures().length || type.getConstructSignatures().length) throw new Error(`${where}: functions are not JSON`)
351
357
  stack.add(type)
package/src/inject.ts CHANGED
@@ -44,6 +44,7 @@ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, t
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
46
  export type { ChannelSubscription } from "./server/channel"
47
+ export type { StoredFile, StoredImage } from "./server/files/models"
47
48
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
48
49
  // Game audio (docs/audio-plan.md): decoded clips, a voice pool, buses with effects; 3D through the
49
50
  // AudioSource / AudioZone aspects (gl/) and scene.audio.
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * Wire (implemented by the runner):
11
11
  * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
12
+ * — with a `File` among the arguments: multipart, the field `args` is
13
+ * that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
12
14
  * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
13
15
  * WS <serverUrl>/ws the channel socket — its frames are ./wire.ts
14
16
  *
@@ -17,7 +19,7 @@
17
19
  * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
18
20
  */
19
21
 
20
- import { fetch } from "./fetch"
22
+ import { fetch, File, FormData } from "./fetch"
21
23
  import { localStorage } from "./storage"
22
24
  import { WebSocket } from "./net"
23
25
  import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
@@ -64,12 +66,22 @@ export const __rpc = (serverUrl: string, id: string) => {
64
66
  // an optional argument left out is `undefined` in its place — JSON has no such value (it would
65
67
  // travel as null and fail the parameter's type), and it is the last ones
66
68
  while (args.length && args[args.length - 1] === undefined) args.pop()
67
- const headers: Record<string, string> = { "content-type": "application/json" }
69
+ const headers: Record<string, string> = {}
68
70
  const session = getSession(serverUrl)
69
71
  if (session) headers.authorization = `Bearer ${session}`
72
+ // a File anywhere among the arguments leaves its place to a marker and travels beside them
73
+ const files: File[] = []
74
+ const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
75
+ let payload: string | FormData = json
76
+ if (files.length) {
77
+ const form = new FormData()
78
+ form.append("args", json)
79
+ files.forEach((file, i) => form.append(`file${i}`, file, file.name))
80
+ payload = form
81
+ } else headers["content-type"] = "application/json"
70
82
  let res
71
83
  try {
72
- res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
84
+ res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: payload })
73
85
  } catch (e) {
74
86
  throw new RpcError(0, `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})`)
75
87
  }
@@ -15,6 +15,10 @@ export type RequestContext = {
15
15
  sessionToken?: string
16
16
  /** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
17
17
  auth?: unknown
18
+ /** The files a multipart call carried, as the receiving process left them (./files/host.ts `Upload`). */
19
+ uploads?: unknown[]
20
+ /** What `/files/<id>/<name>` is appended to in a stored file's url: the backend's own base, as this request reached it. */
21
+ filesBase?: string
18
22
  }
19
23
 
20
24
  type Provider = () => RequestContext | undefined
@@ -8,9 +8,11 @@
8
8
  * never sees a connection string.
9
9
  */
10
10
 
11
- import type { Field, FieldDef, ScalarKind } from "./fields"
11
+ import { t, type Field, type FieldDef, type ScalarKind } from "./fields"
12
12
  import { createAuthApi } from "../auth/api"
13
13
  import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityFields, sessionFields } from "../auth/models"
14
+ import { createFileLayer } from "../files/db"
15
+ import { FILE_MODEL, fileFields, fileOwnerKey, type FileField } from "../files/models"
14
16
  import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
15
17
  import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
16
18
 
@@ -63,7 +65,12 @@ const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> =>
63
65
  for (const [name, m] of Object.entries(models)) {
64
66
  if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`defineDb: model name "${name}" must be PascalCase`)
65
67
  const fields: Record<string, FieldDef> = {}
66
- for (const [key, f] of Object.entries(m.fields as Fields)) fields[key] = { ...(f as Field<any, any>).def }
68
+ for (const [key, f] of Object.entries(m.fields as Fields)) {
69
+ const def = (f as Field<any, any>).def
70
+ if (def.kind === "struct" && Object.values(def.fields!).some(inner => (inner as Field<any, any>).def.kind === "file"))
71
+ throw new Error(`defineDb: ${name}.${key} — a file belongs to a model's row: put the t.file() field on ${name} itself, not inside a struct`)
72
+ fields[key] = { ...def }
73
+ }
67
74
  if (fields.id && !fields.id.isId) fields.id.isId = true
68
75
  const idKeys = Object.keys(fields).filter(k => fields[k].isId)
69
76
  const uuidId = idKeys.length === 1 && idKeys[0] === "id" && fields.id.scalar === "uuid"
@@ -133,6 +140,7 @@ const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): st
133
140
  case "one": type = d.model!; break
134
141
  case "many": type = `${d.model}[]`; attrs.push(`@bind(${d.model}.${d.back})`); break
135
142
  case "list": type = `${d.model}[]`; attrs.push("@list"); break
143
+ case "file": throw new Error(`${owner}.${key}: a file field is emitted as the relation it stands for`)
136
144
  }
137
145
  if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
138
146
  if (d.optional) type += "?"
@@ -164,12 +172,14 @@ export const toMarci = (metas: Record<string, ModelMeta>): string => {
164
172
  * model — `key` / `body` scalars, `one` / `many` relations with their target. Structs are pseudo-models
165
173
  * (`User.info`) so an empty nested select fills with the struct's own scalars, as marcidb does.
166
174
  */
167
- const modelsMeta = (metas: Record<string, ModelMeta>): ModelsMeta => {
175
+ const modelsMeta = (metas: Record<string, ModelMeta>, isFile: (model: string, key: string) => boolean): ModelsMeta => {
168
176
  const out: Record<string, FieldDesc[]> = {}
169
177
  const describe = (name: string, fields: Record<string, FieldDef>, implicitId: boolean) => {
170
178
  const descs: FieldDesc[] = implicitId ? [{ n: "id", k: "key" }] : []
171
179
  for (const [key, d] of Object.entries(fields)) {
172
- if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
180
+ // a file reads like a scalar: a query without a selection returns it (../files/db.ts makes it the relation select)
181
+ if (isFile(name, key)) descs.push({ n: key, k: "body" })
182
+ else if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
173
183
  else if (d.kind === "struct") {
174
184
  const sub = `${name}.${key}`
175
185
  const inner: Record<string, FieldDef> = {}
@@ -204,21 +214,51 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
204
214
  for (const name of [AUTH_SESSION_MODEL, AUTH_IDENTITY_MODEL]) {
205
215
  if (name in own) throw new Error(`defineDb: "${name}" is the platform's own model (it comes with .withAuth) — name yours differently`)
206
216
  }
217
+ if (FILE_MODEL in own) throw new Error(`defineDb: "${FILE_MODEL}" is the platform's own model (it comes with a t.file() field) — name yours differently`)
218
+ // the schema's file fields: each is, in the database, the reverse side of a reference on the platform's File
219
+ const fileList: FileField[] = []
220
+ for (const [name, m] of Object.entries(own)) {
221
+ for (const [key, f] of Object.entries(m.fields as Fields)) {
222
+ const def = (f as Field<any, any>).def
223
+ if (def.kind === "file") fileList.push({ model: name, key, array: def.array, owner: fileOwnerKey(name, key), ...(def.image ? { image: def.image } : {}) })
224
+ }
225
+ }
207
226
  // What is wrong with the project's own models is said HERE, at the definition, not at the first use:
208
227
  // checked against a stand-in for the one platform model they may point at.
209
228
  buildMeta({ ...own, [AUTH_SESSION_MODEL]: model({}) })
210
229
 
211
230
  let authModel: string | null = null
212
- let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null } | null = null
231
+ let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null, files: FileField[] } | null = null
213
232
 
214
233
  const transport = () => {
215
234
  const t = options.transport ?? seam.__lecodesDbTransport
216
235
  if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
217
236
  return t
218
237
  }
238
+ // A db with file fields runs every operation through the file layer: a write of a file field is the
239
+ // row's operation and the ones on File after it, in one transaction.
240
+ let files: ReturnType<typeof createFileLayer> | null = null
241
+ const exec = async (op: MarciOp): Promise<any> => {
242
+ if (!files) return transport().exec(op)
243
+ const plan = await files.plan(op, 0)
244
+ if (!plan.after.length) return files.result(plan.op, await transport().exec(plan.op))
245
+ return (await transport().batch([plan.op, ...plan.after]))[0]
246
+ }
247
+ const batch = async (ops: MarciOp[]): Promise<any[]> => {
248
+ if (!files) return transport().batch(ops)
249
+ // the project's operations keep their places (its `ref`s count them); what follows them goes after
250
+ const plans: Awaited<ReturnType<typeof files.plan>>[] = []
251
+ for (let index = 0; index < ops.length; index++) plans.push(await files.plan(ops[index], index))
252
+ const results = await transport().batch([...plans.map(p => p.op), ...plans.flatMap(p => p.after)])
253
+ return plans.map((p, index) => files!.result(p.op, results[index]))
254
+ }
219
255
  const seal = () => {
220
256
  if (sealed) return sealed
221
257
  const models: Record<string, Model> = { ...own }
258
+ if (fileList.length) {
259
+ for (const f of fileList) models[f.model] = model({ ...(models[f.model].fields as Fields), [f.key]: t.many(FILE_MODEL, f.owner) })
260
+ models[FILE_MODEL] = model(fileFields(fileList))
261
+ }
222
262
  if (authModel) {
223
263
  models[AUTH_SESSION_MODEL] = model(sessionFields(authModel))
224
264
  models[AUTH_IDENTITY_MODEL] = model(identityFields(authModel))
@@ -246,10 +286,21 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
246
286
  for (const [key, value] of missing) out[key] = [...value]
247
287
  return out
248
288
  }
289
+ const isFile = (name: string, key: string) => fileList.some(f => f.model === name && f.key === key)
290
+ const meta = modelsMeta(metas, isFile)
291
+ if (fileList.length) {
292
+ const relations: Record<string, Record<string, string>> = {}
293
+ const scalars: Record<string, string[]> = {}
294
+ for (const [name, descs] of Object.entries(meta)) {
295
+ relations[name] = Object.fromEntries(descs.filter(d => d.m).map(d => [d.n, d.m!]))
296
+ scalars[name] = descs.filter(d => d.k === "key" || d.k === "body").map(d => d.n)
297
+ }
298
+ files = createFileLayer(fileList, relations, scalars)
299
+ }
249
300
  // marcidb's own query builder over our metadata; the transport is the only host-specific piece
250
301
  const { collection } = createQueryLayer({
251
- models: modelsMeta(metas),
252
- run: op => transport().exec(op),
302
+ models: meta,
303
+ run: exec,
253
304
  // a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
254
305
  prepareInsert: (name, raw) => {
255
306
  const data = withDefaults(name, raw)
@@ -262,12 +313,13 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
262
313
  models, schema: toMarci(metas), collections,
263
314
  // the platform's models are reached by role, never as `db.session` / `db.identity`
264
315
  auth: authModel ? { model: authModel, user: collection(authModel), session: collection(AUTH_SESSION_MODEL), identity: collection(AUTH_IDENTITY_MODEL) } : null,
316
+ files: fileList,
265
317
  }
266
318
  return sealed
267
319
  }
268
320
 
269
321
  const db: Record<string, unknown> = {
270
- $transaction: async (ops: unknown[]) => { seal(); return transport().batch(ops.map(opOf)) },
322
+ $transaction: async (ops: unknown[]) => { seal(); return batch(ops.map(opOf)) },
271
323
  withAuth: ({ model: name }: { model: string }) => {
272
324
  if (sealed) throw new Error("defineDb(...).withAuth(): this db has already been used — sign-in is part of the definition, write it on defineDb(...) itself")
273
325
  if (authModel) throw new Error("defineDb(...).withAuth(): called twice on one db")
@@ -276,7 +328,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
276
328
  for (const [key, f] of Object.entries(user.fields as Fields)) {
277
329
  const d = (f as Field<any, any>).def
278
330
  if (d.isId || key === "id") throw new Error(`defineDb(...).withAuth(): ${name} must keep the built-in id (it declares "${key}" as its key) — sessions and sign-in methods point at it`)
279
- if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault) continue
331
+ if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault || (d.kind === "file" && d.array)) continue
280
332
  throw new Error(`defineDb(...).withAuth(): ${name}.${key} needs .optional() or .default(...) — the platform creates the row the first time someone signs in`)
281
333
  }
282
334
  authModel = name
@@ -288,6 +340,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
288
340
  $schema: { get: () => seal().schema },
289
341
  $models: { get: () => seal().models },
290
342
  $auth: { get: () => seal().auth },
343
+ $files: { get: () => seal().files },
291
344
  })
292
345
  for (const name of Object.keys(own)) {
293
346
  Object.defineProperty(db, uncap(name), { enumerable: true, get: () => seal().collections[name] })
@@ -7,9 +7,10 @@
7
7
  */
8
8
 
9
9
  import type { JsonValue } from "./marci/query"
10
+ import type { StoredFile, StoredImage } from "../files/models"
10
11
 
11
12
  export type ScalarKind = "string" | "int" | "float" | "bool" | "date" | "uuid" | ""
12
- export type FieldKind = "scalar" | "json" | "enum" | "struct" | "one" | "many" | "list"
13
+ export type FieldKind = "scalar" | "json" | "enum" | "struct" | "one" | "many" | "list" | "file"
13
14
  export type OnDelete = "cascade" | "setNull" | "restrict"
14
15
 
15
16
  /** Phantom metadata — the type-level twin of `FieldDef`. */
@@ -48,6 +49,8 @@ export type FieldDef = {
48
49
  fields?: Record<string, Field<any, any>>
49
50
  /** one / many / list */
50
51
  model?: string
52
+ /** file: a picture (`t.image()`) — `max` is the most its longer side may be, px. */
53
+ image?: { max: number }
51
54
  /** many: the field on the related model that holds the reference (`@bind`); resolved by defineDb when omitted. */
52
55
  back?: string
53
56
  }
@@ -126,6 +129,23 @@ export const t = {
126
129
  /** Reverse side of a `t.one()` on `model` (the field is inferred when unambiguous). Read-only list. */
127
130
  many: <N extends string>(model: N, backField?: string) =>
128
131
  create<never, Meta<{ kind: "many", model: N }>>(base("many", { model, back: backField })),
132
+ /**
133
+ * A stored file: written as a `File` (an upload — an endpoint's parameter), read as
134
+ * `{ url, name, type, size, width?, height? }`. `.optional()` = may be empty, `.array()` = an ordered
135
+ * list of files. It is deleted with its row — or when the field is given another file, or `null`.
136
+ */
137
+ file: () => create<StoredFile, Meta<{ kind: "file" }>>(base("file")),
138
+ /**
139
+ * A stored PICTURE — a `t.file()` that takes images only and keeps them fit to show: turned the way
140
+ * the camera meant, without its metadata (a photo's place and time), as WebP, and no larger than
141
+ * `max` px on its longer side (2048 when not said; a picture is never enlarged).
142
+ * Read as a `StoredFile` whose `width` and `height` are always there. A GIF is kept as it is.
143
+ */
144
+ image: (options: { max?: number } = {}) => {
145
+ const max = options.max ?? 2048
146
+ if (!Number.isInteger(max) || max < 1) throw new Error(`t.image({ max }): a whole number of pixels is expected, got ${max}`)
147
+ return create<StoredImage, Meta<{ kind: "file" }>>(base("file", { image: { max } }))
148
+ },
129
149
  /** Ordered relation list stored inline (`@list`) — hand-arranged collections, duplicates allowed. */
130
150
  list: <N extends string>(model: N) => create<never, Meta<{ kind: "list", model: N }>>(base("list", { model })),
131
151
  }
@@ -69,7 +69,7 @@ export const marciRequest = async (
69
69
 
70
70
  export const marciHttpTransport = (baseUrl: string, options: MarciHttpOptions = {}): MarciTransport => {
71
71
  const url = baseUrl.replace(/\/+$/, "")
72
- const call = (path: string, body?: unknown) => marciRequest(`${url}/${path}`, { body, token: options.token, fetch: options.fetch })
72
+ const call = (path: string, body?: unknown, method?: string) => marciRequest(`${url}/${path}`, { body, method, token: options.token, fetch: options.fetch })
73
73
  return {
74
74
  exec(op: MarciOp) {
75
75
  switch (op.action) {
@@ -83,6 +83,14 @@ export const marciHttpTransport = (baseUrl: string, options: MarciHttpOptions =
83
83
  case "deleteMany": return call(`${op.model}/deleteMany`, op.query ?? {})
84
84
  case "count": return call(`${op.model}/count`, op.query ?? {})
85
85
  case "aggregate": return call(`${op.model}/aggregate`, op.query ?? {})
86
+ // a journal of the model's changes: create, read (`after` confirms), drop
87
+ case "$journalOpen": return call(`${op.model}/$journal/${op.journal!.name}`, { on: op.journal!.on })
88
+ case "$journalRead": {
89
+ const { name, after, limit, wait } = op.journal!
90
+ const params = [after !== undefined && `after=${after}`, limit !== undefined && `limit=${limit}`, wait && `wait=${wait}`].filter(Boolean).join("&")
91
+ return call(`${op.model}/$journal/${name}${params ? "?" + params : ""}`, undefined, "GET")
92
+ }
93
+ case "$journalDrop": return call(`${op.model}/$journal/${op.journal!.name}`, undefined, "DELETE")
86
94
  default: return Promise.reject(new Error(`marcidb: unknown action '${op.action}'`))
87
95
  }
88
96
  },
@@ -7,7 +7,7 @@
7
7
  // per-model type bags and MODELS metadata this file is parametrised by.
8
8
  //
9
9
  // Keep it dependency-free: besides shipping in `marcidb-client/runtime`, this file is vendored verbatim
10
- // into the lecodes SDK (`sdk/src/server/db/marci/query.ts`, `bun run sync:marcidb` there), which
10
+ // into the lecodes SDK (`packages/sdk/src/server/db/marci/query.ts`, `bun run sync:marcidb` there), which
11
11
  // derives its model types from TS builders instead of codegen and plugs them into the same generics.
12
12
 
13
13
  // ───────────────────────────── query language types ─────────────────────────────
@@ -177,7 +177,9 @@ 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 }
180
+ export type MarciOp = { model: string, action: string, query?: any, data?: any, id?: any, journal?: JournalArgs }
181
+ /** The arguments of a journal action (`$journalOpen` / `$journalRead` / `$journalDrop`). `wait` is in seconds. */
182
+ export type JournalArgs = { name: string, on?: readonly JournalOp[], after?: number, limit?: number, wait?: number }
181
183
  export type MarciTransport = {
182
184
  exec(op: MarciOp): Promise<any>
183
185
  batch(ops: MarciOp[]): Promise<any[]>
@@ -275,8 +277,35 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
275
277
  findFirst<Q extends T["query"] = {}>(query?: Q): Op<Rows<T, Q> | null>
276
278
  }
277
279
 
278
- /** `db.<model>`: the root query, plus `reindex()` for models with a `@custom` (vector / full-text) index. */
279
- export type Collection<T extends ModelTypes> = Query<T> & (T["reindex"] extends true ? { reindex(): Op<{ ok: boolean, indexed: number }> } : {})
280
+ // ───────────────────────────── journals ─────────────────────────────
281
+
282
+ /** What a journal can record. Only deletes so far. */
283
+ export type JournalOp = "delete"
284
+ /** One recorded change. `row` is the row as it last was: its id and every scalar field. */
285
+ export type JournalEntry<T extends ModelTypes, O extends JournalOp = JournalOp> = { seq: number, op: O, row: Rows<T, T["scalars"]> }
286
+ export type JournalOptions<O extends JournalOp> = {
287
+ /** The operations to record. A journal that exists with other ones is an error, not a redefinition. */
288
+ on: O | readonly O[]
289
+ /** `false`: the loop ends when the journal is read through. By default it waits for the next entry. */
290
+ wait?: boolean
291
+ }
292
+ /**
293
+ * A named, durable log of a model's changes. It starts recording when it is first asked for and keeps
294
+ * every entry until the loop that reads it has moved past it — an entry whose loop body threw, or that
295
+ * was never reached, is delivered again to the next reader of the same name.
296
+ */
297
+ export interface Journal<T extends ModelTypes, O extends JournalOp = JournalOp> extends AsyncIterable<JournalEntry<T, O>> {
298
+ /** Drops the journal with whatever it still holds; the model's writes stop paying for it. */
299
+ drop(): Promise<void>
300
+ }
301
+
302
+ /**
303
+ * `db.<model>`: the root query, plus `reindex()` for models with a `@custom` (vector / full-text) index and
304
+ * `$journal(name, { on })` — the journal of this model's changes under that name, created on first use.
305
+ */
306
+ export type Collection<T extends ModelTypes> = Query<T>
307
+ & (T["reindex"] extends true ? { reindex(): Op<{ ok: boolean, indexed: number }> } : {})
308
+ & { $journal<O extends JournalOp>(name: string, options: JournalOptions<O>): Journal<T, O> }
280
309
 
281
310
  // ───────────────────────────── builder runtime ─────────────────────────────
282
311
 
@@ -345,6 +374,43 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
345
374
  return out;
346
375
  };
347
376
 
377
+ // `db.<model>.$journal(name, { on })`. The journal is opened at once — what happens between this call
378
+ // and the first read is already recorded. Reading confirms: each request carries the seq of the last
379
+ // entry the loop got past, which is what lets the engine drop it.
380
+ const journal = (model: string, name: string, options: { on: any; wait?: boolean }): any => {
381
+ const on = Array.isArray(options.on) ? options.on : [options.on];
382
+ const wait = options.wait === false ? 0 : 30;
383
+ const opened = run({ model, action: "$journalOpen", journal: { name, on } });
384
+ opened.catch(() => {}); // reported by the first read, not as an unhandled rejection
385
+ const read = (after: number | undefined, limit: number, wait: number): Promise<any[]> =>
386
+ run({ model, action: "$journalRead", journal: { name, after, limit, wait } });
387
+ return {
388
+ async *[Symbol.asyncIterator]() {
389
+ await opened;
390
+ let done: number | undefined; // the last entry the loop body finished
391
+ let confirmed: number | undefined; // the last one a request has carried
392
+ try {
393
+ for (;;) {
394
+ const entries = await read(done, 100, wait);
395
+ confirmed = done;
396
+ if (entries.length === 0) {
397
+ if (wait === 0) return;
398
+ continue;
399
+ }
400
+ for (const entry of entries) {
401
+ yield entry;
402
+ done = entry.seq;
403
+ }
404
+ }
405
+ } finally {
406
+ // Left mid-batch (`break`, a throw): confirm what was finished, so it is not delivered again.
407
+ if (done !== undefined && done !== confirmed) await read(done, 1, 0).catch(() => {});
408
+ }
409
+ },
410
+ drop: () => opened.catch(() => {}).then(() => run({ model, action: "$journalDrop", journal: { name } })).then(() => {}),
411
+ };
412
+ };
413
+
348
414
  // `db.<model>` — an immutable builder; each clause returns a new one over the same `run`.
349
415
  const collection = (model: string): any => {
350
416
  const make = (st: QueryState): any => {
@@ -400,6 +466,7 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
400
466
  return op({ model, action: "deleteMany", query: whereOnly() });
401
467
  },
402
468
  reindex: () => op({ model, action: "$reindex" }),
469
+ $journal: (name: string, options: { on: any; wait?: boolean }) => journal(model, name, options),
403
470
 
404
471
  findMany: (query?: Record<string, any>) => op({ model, action: "findMany", query: build(query) }),
405
472
  findFirst: (query?: Record<string, any>) => op({ model, action: "findFirst", query: build(query) }),
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Field, FieldMeta } from "./fields"
11
11
  import type { IdentityPublicFields, SessionPublicFields } from "../auth/models"
12
+ import type { FileField, StoredFile, UploadedFile as File } from "../files/models"
12
13
  import type {
13
14
  CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearchValue,
14
15
  FullTextSearch, JsonCondition, JsonPathWhere, Op, PrimitiveListUpdate, Query as MarciQuery, RefListUpdate,
@@ -49,13 +50,16 @@ type FieldRow<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
49
50
  M["kind"] extends "one" ? Nullable<M, Row<S, ModelName<S, Fld>>>
50
51
  : M["kind"] extends "many" | "list" ? Row<S, ModelName<S, Fld>>[]
51
52
  : M["kind"] extends "struct" ? Nullable<M, StructRow<S, StructFieldsOf<Fld>>>
52
- : Nullable<M, Arr<M, TsOf<Fld>>>
53
+ : Nullable<M, Arr<M, TsOf<Fld>>> // a file too: `StoredFile`, `| null`, or a list
53
54
 
54
55
  /** Full row type of a model (relations included) — the `TModel` marcidb's `GetResult` selects from. */
55
56
  export type Row<S, N extends keyof S> = Id<S, N> & { [K in Exclude<keyof FieldsOf<S[N]>, IdKeys<FieldsOf<S[N]>>>]: FieldRow<S, FieldsOf<S[N]>[K]> }
56
57
  type StructRow<S, F extends Fields> = { [K in keyof F]: FieldRow<S, F[K]> }
57
58
 
58
- type ScalarKeys<F extends Fields> = { [K in keyof F]: MetaOf<F[K]>["kind"] extends "scalar" | "enum" | "json" ? K : never }[keyof F]
59
+ /** What a query without a selection returns besides the id: the scalars, and the files. */
60
+ type ScalarKeys<F extends Fields> = { [K in keyof F]: MetaOf<F[K]>["kind"] extends "scalar" | "enum" | "json" | "file" ? K : never }[keyof F]
61
+ /** The fields a row is ordered and aggregated by. */
62
+ type ValueKeys<F extends Fields> = { [K in keyof F]: MetaOf<F[K]>["kind"] extends "scalar" | "enum" | "json" ? K : never }[keyof F]
59
63
  type NumKeys<F extends Fields> = { [K in keyof F]: IsNum<MetaOf<F[K]>> extends true ? (MetaOf<F[K]>["array"] extends true ? never : K) : never }[keyof F]
60
64
  /** What an empty select returns: id + every scalar field. */
61
65
  export type Scalars<S, N extends keyof S> = Pick<Row<S, N>, (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>>
@@ -68,6 +72,7 @@ type FieldInsert<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
68
72
  M["kind"] extends "one" ? Nullable<M, Id<S, ModelName<S, Fld>>>
69
73
  : M["kind"] extends "many" | "list" ? Id<S, ModelName<S, Fld>>[]
70
74
  : M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld>>>
75
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, File>> // a new row takes uploads
71
76
  : Nullable<M, Arr<M, TsOf<Fld>>>
72
77
 
73
78
  type RequiredKeys<F extends Fields> = {
@@ -75,6 +80,7 @@ type RequiredKeys<F extends Fields> = {
75
80
  : MetaOf<F[K]>["optional"] extends true ? never
76
81
  : MetaOf<F[K]>["hasDefault"] extends true ? never
77
82
  : MetaOf<F[K]>["kind"] extends "many" | "list" ? never
83
+ : MetaOf<F[K]>["kind"] extends "file" ? (MetaOf<F[K]>["array"] extends true ? never : K)
78
84
  : K
79
85
  }[keyof F]
80
86
  type Simplify<T> = { [K in keyof T]: T[K] } & {}
@@ -92,6 +98,8 @@ type FieldUpdate<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
92
98
  : M["kind"] extends "many" ? RefListUpdate<Id<S, ModelName<S, Fld>>>
93
99
  : M["kind"] extends "list" ? RefListUpdateOrdered<Id<S, ModelName<S, Fld>>>
94
100
  : M["kind"] extends "struct" ? RefUpdateStruct<StructInsert<S, StructFieldsOf<Fld>>, UpdateOf<S, StructFieldsOf<Fld>>> | null
101
+ // an upload replaces; the stored file the field holds, handed back, keeps it; null / a shorter list deletes
102
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, File | StoredFile>>
95
103
  : M["array"] extends true ? TsOf<Fld>[] | PrimitiveListUpdate<TsOf<Fld>>
96
104
  : Nullable<M, TsOf<Fld> | (IsNum<M> extends true ? UpdateNumValue : never)>
97
105
 
@@ -120,6 +128,7 @@ type FieldWhere<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
120
128
  : M["kind"] extends "many" | "list" ? CompareRefListValue<Where<S, ModelName<S, Fld>>>
121
129
  : M["kind"] extends "struct" ? CompareRefValue<WhereValue<StructWhereFields<S, StructFieldsOf<Fld>>> | null>
122
130
  : M["kind"] extends "json" ? JsonPathWhere | JsonCondition
131
+ : M["kind"] extends "file" ? never // a row is not found by its file
123
132
  : M["array"] extends true ? TsOf<Fld>[] // the whole array, equal — the engine has no filter by an item
124
133
  : ScalarWhere<TsOf<Fld>, M> | CustomSearchValue<FullTextSearch> | CustomSearchValue<VectorSearch>
125
134
 
@@ -128,7 +137,7 @@ type IdWhere<S, N extends keyof S> = { [K in keyof Id<S, N> as K extends keyof F
128
137
  export type Where<S, N extends keyof S> = WhereValue<WhereFieldsOf<S, FieldsOf<S[N]>> & IdWhere<S, N>>
129
138
  type StructWhereFields<S, F extends Fields> = WhereFieldsOf<S, F>
130
139
 
131
- type OrderKeys<S, N extends keyof S> = (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>
140
+ type OrderKeys<S, N extends keyof S> = (ValueKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>
132
141
  /** ONE field and its direction: the engine sorts by a single field, so a second key is a type error. */
133
142
  export type Order<S, N extends keyof S> = {
134
143
  [K in OrderKeys<S, N>]: { [P in K]: "asc" | "desc" } & { [P in Exclude<OrderKeys<S, N>, K>]?: never }
@@ -148,8 +157,8 @@ export type AggregateQuery<S, N extends keyof S> = {
148
157
  $count?: true
149
158
  $sum?: NumKeys<FieldsOf<S[N]>> & string
150
159
  $avg?: NumKeys<FieldsOf<S[N]>> & string
151
- $min?: (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & string
152
- $max?: (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & string
160
+ $min?: (ValueKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & string
161
+ $max?: (ValueKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & string
153
162
  }
154
163
 
155
164
  // ───────────────────────────── query / db ─────────────────────────────
@@ -197,6 +206,8 @@ type DbBase<S extends Schema> = {
197
206
  /** @internal */ readonly $models: Record<string, Model>
198
207
  /** @internal the collections sign-in works on; null = a db without `withAuth`. */
199
208
  readonly $auth: AuthBinding | null
209
+ /** @internal the schema's `t.file()` fields (../files/models.ts). */
210
+ readonly $files: FileField[]
200
211
  }
201
212
 
202
213
  export type Db<S extends Schema> = DbBase<S> & {