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