lecodes-sdk 2.0.6 → 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/dist/global.d.ts +4 -0
- package/dist/types/inject.d.ts +1 -0
- package/dist/types/runtime/rpc.d.ts +2 -0
- package/dist/types/server/context.d.ts +4 -0
- package/dist/types/server/db/defineDb.d.ts +1 -1
- package/dist/types/server/db/fields.d.ts +25 -1
- package/dist/types/server/db/marci/query.d.ts +39 -2
- package/dist/types/server/db/types.d.ts +13 -7
- package/dist/types/server/files/db.d.ts +39 -0
- package/dist/types/server/files/models.d.ts +131 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +3 -2
- package/src/compile/libraryImports.ts +9 -0
- package/src/compile/serverTypes.ts +6 -0
- package/src/inject.ts +1 -0
- package/src/runtime/rpc.ts +15 -3
- package/src/server/context.ts +4 -0
- package/src/server/db/defineDb.ts +62 -9
- package/src/server/db/fields.ts +21 -1
- package/src/server/db/httpTransport.ts +9 -1
- package/src/server/db/marci/query.ts +71 -4
- package/src/server/db/types.ts +16 -5
- package/src/server/files/db.ts +182 -0
- package/src/server/files/host.ts +496 -0
- package/src/server/files/models.ts +116 -0
- package/src/server/host.ts +17 -4
- package/src/server/inject.ts +1 -0
- package/src/server/runtime.ts +41 -0
- package/src/server/validate.ts +5 -0
- package/src/version.ts +1 -1
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.
|
|
45
|
+
"marcidb-embedded": "^0.14.1",
|
|
46
|
+
"sharp": "^0.35.2"
|
|
46
47
|
},
|
|
47
|
-
"version": "2.0.
|
|
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.
|
package/src/runtime/rpc.ts
CHANGED
|
@@ -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> = {
|
|
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:
|
|
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
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
|
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))
|
|
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
|
|
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
|
-
|
|
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:
|
|
252
|
-
run:
|
|
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
|
|
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] })
|
package/src/server/db/fields.ts
CHANGED
|
@@ -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
|
-
|
|
279
|
-
|
|
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) }),
|
package/src/server/db/types.ts
CHANGED
|
@@ -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
|
-
|
|
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> = (
|
|
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?: (
|
|
152
|
-
$max?: (
|
|
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> & {
|