lecodes-sdk 2.0.7 → 2.0.9

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 (68) hide show
  1. package/dist/global.d.ts +6 -0
  2. package/dist/types/g2/Scene2D.d.ts +4 -0
  3. package/dist/types/gl/Light.d.ts +36 -1
  4. package/dist/types/gl/Scene.d.ts +4 -0
  5. package/dist/types/inject.d.ts +2 -1
  6. package/dist/types/runtime/rpc.d.ts +2 -0
  7. package/dist/types/server/context.d.ts +4 -0
  8. package/dist/types/server/db/defineDb.d.ts +1 -1
  9. package/dist/types/server/db/fields.d.ts +25 -1
  10. package/dist/types/server/db/marci/query.d.ts +39 -2
  11. package/dist/types/server/db/types.d.ts +13 -7
  12. package/dist/types/server/files/db.d.ts +39 -0
  13. package/dist/types/server/files/models.d.ts +131 -0
  14. package/dist/types/server/inject.d.ts +1 -0
  15. package/dist/types/ui/NativeView.d.ts +3 -0
  16. package/dist/types/ui/UI.d.ts +1 -0
  17. package/dist/types/ui/UILayer.d.ts +25 -0
  18. package/dist/types/ui/UIModal.d.ts +20 -16
  19. package/dist/types/ui/UIPopover.d.ts +5 -3
  20. package/dist/types/ui/UIVideo.d.ts +3 -0
  21. package/dist/types/ui/UIWidget.d.ts +25 -24
  22. package/dist/types/ui/presentable.d.ts +10 -0
  23. package/dist/types/ui/transitions.d.ts +2 -1
  24. package/dist/types/ui/tree.d.ts +1 -0
  25. package/dist/types/version.d.ts +1 -1
  26. package/dist/types.json +1 -1
  27. package/package.json +3 -2
  28. package/prompts/3d-scene-files.md +3 -3
  29. package/prompts/3d-scene.md +1 -1
  30. package/prompts/dist/3d-app.md +4 -4
  31. package/src/bridges/gl.d.ts +5 -0
  32. package/src/bridges/tree.d.ts +24 -12
  33. package/src/chisel.ts +1 -1
  34. package/src/compile/libraryImports.ts +9 -0
  35. package/src/compile/serverTypes.ts +6 -0
  36. package/src/g2/Scene2D.ts +10 -0
  37. package/src/gl/Light.ts +78 -2
  38. package/src/gl/Material.ts +3 -0
  39. package/src/gl/Scene.ts +11 -1
  40. package/src/inject.ts +2 -0
  41. package/src/runtime/rpc.ts +15 -3
  42. package/src/server/context.ts +4 -0
  43. package/src/server/db/defineDb.ts +62 -9
  44. package/src/server/db/fields.ts +21 -1
  45. package/src/server/db/httpTransport.ts +9 -1
  46. package/src/server/db/marci/query.ts +71 -4
  47. package/src/server/db/types.ts +16 -5
  48. package/src/server/files/db.ts +182 -0
  49. package/src/server/files/host.ts +496 -0
  50. package/src/server/files/models.ts +116 -0
  51. package/src/server/host.ts +17 -4
  52. package/src/server/inject.ts +1 -0
  53. package/src/server/runtime.ts +41 -0
  54. package/src/server/validate.ts +5 -0
  55. package/src/ui/NativeView.ts +11 -0
  56. package/src/ui/UI.ts +1 -0
  57. package/src/ui/UIBottomSheet.ts +8 -7
  58. package/src/ui/UILayer.ts +88 -0
  59. package/src/ui/UIModal.ts +59 -43
  60. package/src/ui/UINode.ts +9 -1
  61. package/src/ui/UIPopover.ts +23 -9
  62. package/src/ui/UIVideo.ts +11 -0
  63. package/src/ui/UIWidget.ts +70 -44
  64. package/src/ui/presentable.ts +11 -1
  65. package/src/ui/transitions.ts +36 -7
  66. package/src/ui/tree.ts +4 -0
  67. package/src/version.ts +1 -1
  68. package/tests/helpers/fakeTree.ts +7 -3
@@ -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> & {
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The db layer's half of stored files (./models.ts): what turns a file FIELD — a value in a row — into
3
+ * operations on the platform's `File` model, and back.
4
+ *
5
+ * read the field is selected as the reverse relation it is (the `File` rows that point at the
6
+ * row, in order) and comes back as a `StoredFile`, or a list of them
7
+ * insert the row is inserted without the field; each upload's `File` row is then pointed at it
8
+ * (`ref` to the insert's id) — one transaction
9
+ * update the row's files that are no longer listed are deleted, the uploads are pointed at it —
10
+ * one transaction with the update itself
11
+ *
12
+ * An upload written to a `t.image()` field is first made a picture fit to show (the host's work,
13
+ * ./host.ts): that is why a plan is awaited. What it became — its type, its size — goes to its row
14
+ * in the operation that attaches it.
15
+ *
16
+ * A file is written from the FILE's side (its reference to the owner): that is the side the database
17
+ * cascades over. Only an upload of the current request is ever attached: a `StoredFile` the app hands
18
+ * back means "keep this one", and one of another row is not moved — a client that knows the url of
19
+ * someone else's file cannot take it.
20
+ */
21
+
22
+ import type { MarciOp } from "../db/marci/query"
23
+ import { FILE_MODEL, fileUrl, filesSeam, storedFileId, type FileField, type StoredFile } from "./models"
24
+
25
+ type Plan = { op: MarciOp, after: MarciOp[] }
26
+
27
+ export type FileLayer = {
28
+ /** The operation as the database runs it, and the ones that follow it in the same transaction.
29
+ * `index` is the operation's place in that transaction (what `ref` counts from). */
30
+ plan(op: MarciOp, index: number): Promise<Plan>
31
+ /** The database's answer to a planned operation, as the project reads it. */
32
+ result(op: MarciOp, value: any): any
33
+ }
34
+
35
+ const FILE_SELECT = { id: true, name: true, type: true, size: true, width: true, height: true, $order: { position: "asc" } }
36
+
37
+ const stored = (row: any): StoredFile => {
38
+ const out: StoredFile = { url: fileUrl(row.id, row.name), name: row.name, type: row.type, size: row.size }
39
+ if (row.width != null) out.width = row.width
40
+ if (row.height != null) out.height = row.height
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * `fields`: the schema's `t.file()` fields. `relations`: model → relation field → its target model.
46
+ * `scalars`: model → what a query without a selection returns (file fields included).
47
+ */
48
+ export const createFileLayer = (
49
+ fields: FileField[],
50
+ relations: Record<string, Record<string, string>>,
51
+ scalars: Record<string, string[]>,
52
+ ): FileLayer => {
53
+ const byModel: Record<string, Record<string, FileField>> = {}
54
+ for (const f of fields) (byModel[f.model] ??= {})[f.key] = f
55
+ const hasFiles = (model: string) => model in byModel
56
+
57
+ // ── read ──
58
+
59
+ /** The query with every selected file field as the relation select it is. */
60
+ const select = (model: string, query: Record<string, any>): Record<string, any> => {
61
+ const out: Record<string, any> = {}
62
+ for (const key in query) {
63
+ const value = query[key]
64
+ if (key.charCodeAt(0) === 36 /* $ */) { out[key] = value; continue }
65
+ if (byModel[model]?.[key]) { if (value) out[key] = FILE_SELECT; continue }
66
+ const target = relations[model]?.[key]
67
+ if (!target || !value) { out[key] = value; continue }
68
+ // `relation: true` is "its scalars" to the engine, which knows no file among them
69
+ if (value === true) out[key] = hasFiles(target) ? select(target, Object.fromEntries(scalars[target].map(k => [k, true]))) : true
70
+ else out[key] = typeof value === "object" ? select(target, value) : value
71
+ }
72
+ return out
73
+ }
74
+
75
+ const rows = (model: string, query: Record<string, any>, value: any): any => {
76
+ if (Array.isArray(value)) { for (const row of value) rows(model, query, row); return value }
77
+ if (value === null || typeof value !== "object") return value
78
+ for (const key in query) {
79
+ if (key.charCodeAt(0) === 36) continue
80
+ const field = byModel[model]?.[key]
81
+ if (field) {
82
+ const list = Array.isArray(value[key]) ? value[key].map(stored) : []
83
+ value[key] = field.array ? list : list[0] ?? null
84
+ continue
85
+ }
86
+ const target = relations[model]?.[key]
87
+ if (target && query[key] && typeof query[key] === "object") rows(target, query[key], value[key])
88
+ }
89
+ return value
90
+ }
91
+
92
+ // ── write ──
93
+
94
+ /** The row's data without its file fields, and those fields' values as lists. */
95
+ const split = (model: string, data: any): { data: any, files: [FileField, unknown[]][] } => {
96
+ const own = byModel[model]
97
+ if (!own || data === null || typeof data !== "object") return { data, files: [] }
98
+ const rest: Record<string, unknown> = {}
99
+ const files: [FileField, unknown[]][] = []
100
+ for (const key in data) {
101
+ const field = own[key]
102
+ if (!field) { rest[key] = data[key]; continue }
103
+ const value = data[key]
104
+ if (value === undefined) continue
105
+ if (field.array && !Array.isArray(value)) throw new Error(`${model}.${key}: a list of files is expected`)
106
+ files.push([field, field.array ? value : value === null ? [] : [value]])
107
+ }
108
+ return { data: rest, files }
109
+ }
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) : {}
113
+ return { model: FILE_MODEL, action: "update", id: { id }, data: { ...facts, [field.owner]: { $connect: owner }, position } }
114
+ }
115
+
116
+ const insert = async (op: MarciOp, index: number): Promise<Plan> => {
117
+ const { data, files } = split(op.model, op.data)
118
+ const after: MarciOp[] = []
119
+ for (const [field, values] of files) {
120
+ 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))
124
+ }
125
+ }
126
+ return { op: { ...op, data }, after }
127
+ }
128
+
129
+ const update = async (op: MarciOp): Promise<Plan> => {
130
+ const { data, files } = split(op.model, op.data)
131
+ const after: MarciOp[] = []
132
+ for (const [field, values] of files) {
133
+ const keep: string[] = []
134
+ const then: MarciOp[] = []
135
+ for (let position = 0; position < values.length; position++) {
136
+ const value = values[position]
137
+ const upload = filesSeam()?.uploaded(value)
138
+ const id = upload ?? storedFileId(value)
139
+ if (!id) throw new Error(`${op.model}.${field.key}: a file is expected — an upload, or the stored file the field already holds`)
140
+ keep.push(id)
141
+ if (upload) then.push(await attach(field, upload, op.id, position))
142
+ // one that stays: its place in the list — and only where it IS this row's file
143
+ else if (field.array) then.push({ model: FILE_MODEL, action: "updateMany", query: { $where: { id, [field.owner]: op.id } }, data: { position } })
144
+ }
145
+ // what the field held and no longer lists is deleted, with its bytes
146
+ after.push({ model: FILE_MODEL, action: "deleteMany", query: { $where: { [field.owner]: op.id, ...(keep.length ? { id: { $notIn: keep } } : {}) } } }, ...then)
147
+ }
148
+ return { op: { ...op, data }, after }
149
+ }
150
+
151
+ return {
152
+ async plan(op, index) {
153
+ switch (op.action) {
154
+ case "findMany": case "findFirst":
155
+ return { op: { ...op, query: select(op.model, op.query ?? {}) }, after: [] }
156
+ case "count": case "aggregate":
157
+ return { op, after: [] }
158
+ }
159
+ // rows of File go when a row is deleted (itself, or by a cascade) and when a field is rewritten
160
+ const dirty = () => { const seam = filesSeam(); if (seam) seam.dirty = true }
161
+ switch (op.action) {
162
+ case "insert": return insert(op, index)
163
+ case "update": {
164
+ const plan = await update(op)
165
+ if (plan.after.length) dirty()
166
+ return plan
167
+ }
168
+ case "delete": case "deleteMany":
169
+ dirty()
170
+ break
171
+ case "updateMany":
172
+ for (const key in byModel[op.model] ?? {}) {
173
+ if (op.data && key in op.data) throw new Error(`${op.model}.${key}: a file is written to one row — update(id, …), not updateMany`)
174
+ }
175
+ }
176
+ return { op, after: [] }
177
+ },
178
+ result(op, value) {
179
+ return op.action === "findMany" || op.action === "findFirst" ? rows(op.model, op.query ?? {}, value) : value
180
+ },
181
+ }
182
+ }