lecodes-cli 0.16.1 → 0.17.0
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/README.md +27 -4
- package/dist/index.js +171621 -382
- package/package.json +13 -7
- package/runtime/sdk/compile/bundler.ts +10 -2
- package/runtime/sdk/compile/compileProject.ts +10 -2
- package/runtime/sdk/compile/detectEntry.ts +5 -2
- package/runtime/sdk/compile/index.ts +17 -1
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/gl/AnimationClip.ts +174 -0
- package/runtime/sdk/gl/Animator.ts +642 -0
- package/runtime/sdk/gl/CharacterController.ts +6 -1
- package/runtime/sdk/gl/IK.ts +170 -0
- package/runtime/sdk/gl/Material.ts +1 -1
- package/runtime/sdk/gl/ModelAnimation.ts +34 -3
- package/runtime/sdk/gl/Node.ts +27 -1
- package/runtime/sdk/gl/Scene.ts +291 -289
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/inject.ts +10 -0
- package/runtime/sdk/plugins/oauth.ts +61 -0
- package/runtime/sdk/runtime/rpc.ts +218 -0
- package/runtime/sdk/server/auth/appConfig.ts +12 -0
- package/runtime/sdk/server/auth/global.ts +80 -0
- package/runtime/sdk/server/auth/host.ts +318 -0
- package/runtime/sdk/server/auth/models.ts +83 -0
- package/runtime/sdk/server/auth/types.ts +50 -0
- package/runtime/sdk/server/channel.ts +56 -0
- package/runtime/sdk/server/context.ts +36 -0
- package/runtime/sdk/server/db/defineDb.ts +237 -0
- package/runtime/sdk/server/db/fields.ts +132 -0
- package/runtime/sdk/server/db/httpTransport.ts +93 -0
- package/runtime/sdk/server/db/index.ts +7 -0
- package/runtime/sdk/server/db/marci/query.ts +412 -0
- package/runtime/sdk/server/db/types.ts +202 -0
- package/runtime/sdk/server/errors.ts +12 -0
- package/runtime/sdk/server/host.ts +74 -0
- package/runtime/sdk/server/inject.ts +13 -0
- package/runtime/sdk/server/runtime.ts +133 -0
- package/runtime/sdk/server/validate.ts +87 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model({...})` + `defineDb({ User, Post })` — the schema registry and the typed db handle.
|
|
3
|
+
*
|
|
4
|
+
* Runtime responsibilities: validate relation names, resolve `t.many()` back-fields, emit the `.marci`
|
|
5
|
+
* schema text (`db.$schema`), and turn collection calls into lazy `Op`s over a `MarciTransport`
|
|
6
|
+
* (`marcidb-embedded` handle, HTTP client, or a test fake). The transport is either passed to
|
|
7
|
+
* `defineDb(models, { transport })` or provided by the host through `setDbTransport()` — user code
|
|
8
|
+
* never sees a connection string.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Field, FieldDef, ScalarKind } from "./fields"
|
|
12
|
+
import { AUTH_SESSION_MODEL, AUTH_USER_MODEL, authSessionModel, isAuthModel, sessionUserField } from "../auth/models"
|
|
13
|
+
import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
|
|
14
|
+
import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
|
|
15
|
+
|
|
16
|
+
export const model = <F extends Fields>(fields: F): Model<F> => ({ fields, __model: true })
|
|
17
|
+
|
|
18
|
+
// ───────────────────────────── transport ─────────────────────────────
|
|
19
|
+
|
|
20
|
+
// A GLOBAL seam (not a module variable): the compiled server bundle carries its own copy of this
|
|
21
|
+
// module, so the host's `setDbTransport` and the bundle's `defineDb` only meet through globalThis.
|
|
22
|
+
const seam = globalThis as unknown as { __lecodesDbTransport?: MarciTransport, __lecodesDbs?: Db<any>[] }
|
|
23
|
+
/** Host hook: the runner/dev-server installs the project's transport before loading user code. */
|
|
24
|
+
export const setDbTransport = (transport: MarciTransport | null) => {
|
|
25
|
+
if (transport) seam.__lecodesDbTransport = transport
|
|
26
|
+
else delete seam.__lecodesDbTransport
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Every `defineDb` call registers its handle here so the host finds the schema whether or not the
|
|
30
|
+
* project exports `db` (`const db = defineDb(...)` used only inside endpoints is the common case).
|
|
31
|
+
* The host reads and clears the list after evaluating a bundle (`loadServerModules`).
|
|
32
|
+
*/
|
|
33
|
+
export const takeRegisteredDbs = (): Db<any>[] => {
|
|
34
|
+
const dbs = seam.__lecodesDbs ?? []
|
|
35
|
+
seam.__lecodesDbs = []
|
|
36
|
+
return dbs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const opOf = (o: unknown): MarciOp => {
|
|
40
|
+
const op = (o as { __op?: MarciOp })?.__op
|
|
41
|
+
if (!op) throw new Error("db.$transaction: every item must be a db.<model>.<method>(...) call")
|
|
42
|
+
return op
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A reference to a previous operation's result inside `$transaction` (resolved server-side): `ref("0.id")`. */
|
|
46
|
+
export const ref = (path: string): any => ({ $ref: path })
|
|
47
|
+
|
|
48
|
+
// ───────────────────────────── metadata ─────────────────────────────
|
|
49
|
+
|
|
50
|
+
type ModelMeta = {
|
|
51
|
+
name: string
|
|
52
|
+
fields: Record<string, FieldDef>
|
|
53
|
+
idKeys: string[]
|
|
54
|
+
/** id is a single uuid key we generate on insert. */
|
|
55
|
+
uuidId: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isRel = (d: FieldDef) => d.kind === "one" || d.kind === "many" || d.kind === "list"
|
|
59
|
+
|
|
60
|
+
const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> => {
|
|
61
|
+
const metas: Record<string, ModelMeta> = {}
|
|
62
|
+
for (const [name, m] of Object.entries(models)) {
|
|
63
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`defineDb: model name "${name}" must be PascalCase`)
|
|
64
|
+
const fields: Record<string, FieldDef> = {}
|
|
65
|
+
for (const [key, f] of Object.entries(m.fields as Fields)) fields[key] = { ...(f as Field<any, any>).def }
|
|
66
|
+
if (fields.id && !fields.id.isId) fields.id.isId = true
|
|
67
|
+
const idKeys = Object.keys(fields).filter(k => fields[k].isId)
|
|
68
|
+
const uuidId = idKeys.length === 1 && idKeys[0] === "id" && fields.id.scalar === "uuid"
|
|
69
|
+
metas[name] = { name, fields, idKeys, uuidId }
|
|
70
|
+
}
|
|
71
|
+
// relations: targets must exist; `many` needs a back field
|
|
72
|
+
for (const meta of Object.values(metas)) {
|
|
73
|
+
for (const [key, d] of Object.entries(meta.fields)) {
|
|
74
|
+
if (!isRel(d)) continue
|
|
75
|
+
const target = metas[d.model!]
|
|
76
|
+
if (!target) throw new Error(`defineDb: ${meta.name}.${key} references unknown model "${d.model}"`)
|
|
77
|
+
if (d.kind !== "many") continue
|
|
78
|
+
if (d.back) {
|
|
79
|
+
const back = target.fields[d.back]
|
|
80
|
+
if (!back || back.kind !== "one" || back.model !== meta.name)
|
|
81
|
+
throw new Error(`defineDb: ${meta.name}.${key}: "${d.model}.${d.back}" is not a t.one("${meta.name}") field`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const candidates = Object.entries(target.fields).filter(([, f]) => f.kind === "one" && f.model === meta.name).map(([k]) => k)
|
|
85
|
+
if (candidates.length !== 1)
|
|
86
|
+
throw new Error(
|
|
87
|
+
`defineDb: ${meta.name}.${key}: cannot infer the back field on ${d.model} ` +
|
|
88
|
+
(candidates.length ? `(candidates: ${candidates.join(", ")}) — pass it: t.many("${d.model}", "<field>")`
|
|
89
|
+
: `— add a t.one("${meta.name}") field to ${d.model}`))
|
|
90
|
+
d.back = candidates[0]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return metas
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ───────────────────────────── .marci emitter ─────────────────────────────
|
|
97
|
+
|
|
98
|
+
const SCALAR: Record<Exclude<ScalarKind, "">, string> = {
|
|
99
|
+
string: "String", int: "Int", float: "Double", bool: "Bool", date: "DateTime", uuid: "Byte[16]",
|
|
100
|
+
}
|
|
101
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
102
|
+
const ON_DELETE = { cascade: "Cascade", setNull: "SetNull", restrict: "Restrict" } as const
|
|
103
|
+
|
|
104
|
+
const literal = (v: unknown, d: FieldDef): string => {
|
|
105
|
+
if (d.scalar === "date" && v === "now") return "now()"
|
|
106
|
+
if (Array.isArray(v)) return `[${v.map(x => literal(x, { ...d, array: false })).join(", ")}]`
|
|
107
|
+
if (typeof v === "string") return d.kind === "enum" ? v : JSON.stringify(v)
|
|
108
|
+
return String(v)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): string => {
|
|
112
|
+
let type: string
|
|
113
|
+
const attrs: string[] = []
|
|
114
|
+
switch (d.kind) {
|
|
115
|
+
case "scalar":
|
|
116
|
+
type = d.vector ? `Float[${d.vector.size}]` : SCALAR[d.scalar as Exclude<ScalarKind, "">]
|
|
117
|
+
if (d.scalar === "uuid") attrs.push("@format(uuid)")
|
|
118
|
+
break
|
|
119
|
+
case "json": type = "Json"; break
|
|
120
|
+
case "enum": {
|
|
121
|
+
const name = `${owner}${cap(key)}`
|
|
122
|
+
extra.push(`enum ${name} {\n${d.values!.map(v => ` ${v}`).join("\n")}\n}`)
|
|
123
|
+
type = name; break
|
|
124
|
+
}
|
|
125
|
+
case "struct": {
|
|
126
|
+
const name = `${owner}${cap(key)}`
|
|
127
|
+
const inner: string[] = []
|
|
128
|
+
for (const [k, f] of Object.entries(d.fields!)) inner.push(emitField(name, k, (f as Field<any, any>).def, extra))
|
|
129
|
+
extra.push(`struct ${name} {\n${inner.join("\n")}\n}`)
|
|
130
|
+
type = name; break
|
|
131
|
+
}
|
|
132
|
+
case "one": type = d.model!; break
|
|
133
|
+
case "many": type = `${d.model}[]`; attrs.push(`@bind(${d.model}.${d.back})`); break
|
|
134
|
+
case "list": type = `${d.model}[]`; attrs.push("@list"); break
|
|
135
|
+
}
|
|
136
|
+
if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
|
|
137
|
+
if (d.optional) type += "?"
|
|
138
|
+
if (d.isId) attrs.unshift("@id")
|
|
139
|
+
if (d.unique) attrs.push("@unique")
|
|
140
|
+
if (d.index) attrs.push("@index")
|
|
141
|
+
if (d.hasDefault) attrs.push(`@default(${literal(d.default, d)})`)
|
|
142
|
+
if (d.onDelete) attrs.push(`@onDelete(${ON_DELETE[d.onDelete]})`)
|
|
143
|
+
if (d.fulltext) attrs.push(`@fulltext(${d.fulltext})`)
|
|
144
|
+
if (d.vector) attrs.push(`@vector(${d.vector.metric})`)
|
|
145
|
+
return ` ${key.padEnd(12)} ${type}${attrs.length ? " " + attrs.join(" ") : ""}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const toMarci = (metas: Record<string, ModelMeta>): string => {
|
|
149
|
+
const out: string[] = []
|
|
150
|
+
const extra: string[] = []
|
|
151
|
+
for (const meta of Object.values(metas)) {
|
|
152
|
+
const lines = Object.entries(meta.fields).map(([k, d]) => emitField(meta.name, k, d, extra))
|
|
153
|
+
out.push(`model ${meta.name} {\n${lines.join("\n")}\n}`)
|
|
154
|
+
}
|
|
155
|
+
return [...out, ...extra].join("\n\n") + "\n"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ───────────────────────────── queries ─────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The model metadata marcidb's query layer reads (`MODELS` in a generated client): field descriptors per
|
|
162
|
+
* model — `key` / `body` scalars, `one` / `many` relations with their target. Structs are pseudo-models
|
|
163
|
+
* (`User.info`) so an empty nested select fills with the struct's own scalars, as marcidb does.
|
|
164
|
+
*/
|
|
165
|
+
const modelsMeta = (metas: Record<string, ModelMeta>): ModelsMeta => {
|
|
166
|
+
const out: Record<string, FieldDesc[]> = {}
|
|
167
|
+
const describe = (name: string, fields: Record<string, FieldDef>, implicitId: boolean) => {
|
|
168
|
+
const descs: FieldDesc[] = implicitId ? [{ n: "id", k: "key" }] : []
|
|
169
|
+
for (const [key, d] of Object.entries(fields)) {
|
|
170
|
+
if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
|
|
171
|
+
else if (d.kind === "struct") {
|
|
172
|
+
const sub = `${name}.${key}`
|
|
173
|
+
const inner: Record<string, FieldDef> = {}
|
|
174
|
+
for (const [ik, f] of Object.entries(d.fields!)) inner[ik] = (f as Field<any, any>).def
|
|
175
|
+
describe(sub, inner, false)
|
|
176
|
+
descs.push({ n: key, k: "one", m: sub })
|
|
177
|
+
}
|
|
178
|
+
else descs.push({ n: key, k: d.isId ? "key" : "body" })
|
|
179
|
+
}
|
|
180
|
+
out[name] = descs
|
|
181
|
+
}
|
|
182
|
+
for (const meta of Object.values(metas)) describe(meta.name, meta.fields, meta.idKeys.length === 0)
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const uncap = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
|
|
187
|
+
|
|
188
|
+
export type DefineDbOptions = { transport?: MarciTransport }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The schema registry: model name = key (`db.user` ← `User`). Relation strings are checked against the
|
|
192
|
+
* keys both at the type level (`ValidateRefs`) and at runtime.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* The auth models (docs/backend-plan.md §3.4): `auth.model.user()` must be registered as `User`,
|
|
196
|
+
* `auth.model.session()` as `Session`; `Session` is added when the project doesn't declare it (a device
|
|
197
|
+
* session exists before any login), and gets its `user` column exactly when a `User` model exists.
|
|
198
|
+
*/
|
|
199
|
+
const withAuthModels = (models: Record<string, Model>): { models: Record<string, Model>, auth: AuthBinding } => {
|
|
200
|
+
for (const [name, m] of Object.entries(models)) {
|
|
201
|
+
if (isAuthModel(m, "user") && name !== AUTH_USER_MODEL) throw new Error(`defineDb: auth.model.user() must be registered as "${AUTH_USER_MODEL}" (got "${name}")`)
|
|
202
|
+
if (isAuthModel(m, "session") && name !== AUTH_SESSION_MODEL) throw new Error(`defineDb: auth.model.session() must be registered as "${AUTH_SESSION_MODEL}" (got "${name}")`)
|
|
203
|
+
}
|
|
204
|
+
// A plain `model()` named User is just a model (no auth binding); only auth.model.user() binds.
|
|
205
|
+
const user = isAuthModel(models[AUTH_USER_MODEL], "user") ? models[AUTH_USER_MODEL] : null
|
|
206
|
+
let session = models[AUTH_SESSION_MODEL]
|
|
207
|
+
if (session && !isAuthModel(session, "session")) throw new Error(`defineDb: "${AUTH_SESSION_MODEL}" is reserved for auth.model.session()`)
|
|
208
|
+
if (!session) session = authSessionModel()
|
|
209
|
+
if (user && !("user" in session.fields)) session = { ...session, fields: { ...session.fields, user: sessionUserField() } }
|
|
210
|
+
return { models: { ...models, [AUTH_SESSION_MODEL]: session }, auth: { user: user ? AUTH_USER_MODEL : null, session: AUTH_SESSION_MODEL } }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>, options: DefineDbOptions = {}): Db<S> => {
|
|
214
|
+
const { models, auth } = withAuthModels(declared as Record<string, Model>)
|
|
215
|
+
const metas = buildMeta(models)
|
|
216
|
+
const transport = () => {
|
|
217
|
+
const t = options.transport ?? seam.__lecodesDbTransport
|
|
218
|
+
if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
|
|
219
|
+
return t
|
|
220
|
+
}
|
|
221
|
+
// marcidb's own query builder over our metadata; the transport is the only host-specific piece
|
|
222
|
+
const { collection } = createQueryLayer({
|
|
223
|
+
models: modelsMeta(metas),
|
|
224
|
+
run: op => transport().exec(op),
|
|
225
|
+
// a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
|
|
226
|
+
prepareInsert: (name, data) => (metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data),
|
|
227
|
+
})
|
|
228
|
+
const db: Record<string, unknown> = {
|
|
229
|
+
$schema: toMarci(metas),
|
|
230
|
+
$models: models,
|
|
231
|
+
$auth: auth,
|
|
232
|
+
$transaction: async (ops: unknown[]) => transport().batch(ops.map(opOf)),
|
|
233
|
+
}
|
|
234
|
+
for (const name of Object.keys(metas)) db[uncap(name)] = collection(name)
|
|
235
|
+
;(seam.__lecodesDbs ??= []).push(db as Db<any>)
|
|
236
|
+
return db as Db<S>
|
|
237
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `t` — field builders for `model({...})`. Each builder returns an immutable `Field` carrying a runtime
|
|
3
|
+
* `FieldDef` (what `.marci` is emitted from) and two phantom type parameters: the TS value type and a
|
|
4
|
+
* `FieldMeta` record (kind / optional / default / …) that `defineDb`'s type layer projects into row,
|
|
5
|
+
* insert, update, select and where types. Chained modifiers return a NEW field (builders are never
|
|
6
|
+
* mutated), so `const s = t.string(); { a: s, b: s.optional() }` behaves as expected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JsonValue } from "./marci/query"
|
|
10
|
+
|
|
11
|
+
export type ScalarKind = "string" | "int" | "float" | "bool" | "date" | "uuid" | ""
|
|
12
|
+
export type FieldKind = "scalar" | "json" | "enum" | "struct" | "one" | "many" | "list"
|
|
13
|
+
export type OnDelete = "cascade" | "setNull" | "restrict"
|
|
14
|
+
|
|
15
|
+
/** Phantom metadata — the type-level twin of `FieldDef`. */
|
|
16
|
+
export type FieldMeta = {
|
|
17
|
+
kind: FieldKind
|
|
18
|
+
scalar: ScalarKind
|
|
19
|
+
optional: boolean
|
|
20
|
+
hasDefault: boolean
|
|
21
|
+
array: boolean
|
|
22
|
+
isId: boolean
|
|
23
|
+
/** Related model name for one/many/list; "" otherwise. */
|
|
24
|
+
model: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type DefaultMeta = { kind: "scalar", scalar: "", optional: false, hasDefault: false, array: false, isId: false, model: "" }
|
|
28
|
+
export type Meta<O extends Partial<FieldMeta>> = { [P in keyof FieldMeta]: P extends keyof O ? O[P] : DefaultMeta[P] }
|
|
29
|
+
type SetMeta<M extends FieldMeta, K extends keyof FieldMeta, V> = { [P in keyof FieldMeta]: P extends K ? V : M[P] }
|
|
30
|
+
|
|
31
|
+
/** Runtime field definition — everything the `.marci` emitter and the runtime need. */
|
|
32
|
+
export type FieldDef = {
|
|
33
|
+
kind: FieldKind
|
|
34
|
+
scalar?: ScalarKind
|
|
35
|
+
optional: boolean
|
|
36
|
+
unique: boolean
|
|
37
|
+
index: boolean
|
|
38
|
+
hasDefault: boolean
|
|
39
|
+
default?: unknown
|
|
40
|
+
array: boolean
|
|
41
|
+
isId: boolean
|
|
42
|
+
onDelete?: OnDelete
|
|
43
|
+
fulltext?: "multi" | "english" | "russian"
|
|
44
|
+
vector?: { size: number, metric: "cosine" | "euclidean" }
|
|
45
|
+
/** enum */
|
|
46
|
+
values?: readonly string[]
|
|
47
|
+
/** struct */
|
|
48
|
+
fields?: Record<string, Field<any, any>>
|
|
49
|
+
/** one / many / list */
|
|
50
|
+
model?: string
|
|
51
|
+
/** many: the field on the related model that holds the reference (`@bind`); resolved by defineDb when omitted. */
|
|
52
|
+
back?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type DefaultOf<T, M extends FieldMeta> =
|
|
56
|
+
M["scalar"] extends "date" ? "now" | number : M["array"] extends true ? T[] : T
|
|
57
|
+
|
|
58
|
+
export interface Field<T, M extends FieldMeta = FieldMeta> {
|
|
59
|
+
/** phantom (type-level only — never set at runtime) */ readonly __t?: T
|
|
60
|
+
/** phantom (type-level only — never set at runtime) */ readonly __m?: M
|
|
61
|
+
readonly def: FieldDef
|
|
62
|
+
/** Nullable column (`String?`). */
|
|
63
|
+
optional(): Field<T, SetMeta<M, "optional", true>>
|
|
64
|
+
/** `@unique` (on a relation: one-to-one). */
|
|
65
|
+
unique(): Field<T, M>
|
|
66
|
+
/** `@index` — used by `$where`, `$order`, aggregations. */
|
|
67
|
+
index(): Field<T, M>
|
|
68
|
+
/** `@default(...)`; `"now"` on `t.date()` → `now()`. Makes the field optional on insert. */
|
|
69
|
+
default(value: DefaultOf<T, M>): Field<T, SetMeta<M, "hasDefault", true>>
|
|
70
|
+
/** Primitive list (`String[]`). */
|
|
71
|
+
array(): Field<T, SetMeta<M, "array", true>>
|
|
72
|
+
/** `@id` — primary key (several → composite). A field literally named `id` is the key implicitly. */
|
|
73
|
+
id(): Field<T, SetMeta<M, "isId", true>>
|
|
74
|
+
/** Referential policy for `t.one()` (defaults: required → restrict, optional → setNull). */
|
|
75
|
+
onDelete(policy: OnDelete): Field<T, M>
|
|
76
|
+
/** `@fulltext(...)` module index on a string field; query with `$search`. */
|
|
77
|
+
fulltext(lang?: "multi" | "english" | "russian"): Field<T, M>
|
|
78
|
+
/** `Float[n] @vector(metric)` — nearest-neighbour index; query with `$near`. */
|
|
79
|
+
vector(size: number, metric?: "cosine" | "euclidean"): Field<T, M>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const create = <T, M extends FieldMeta>(def: FieldDef): Field<T, M> => {
|
|
83
|
+
const next = (patch: Partial<FieldDef>) => create<any, any>({ ...def, ...patch })
|
|
84
|
+
return {
|
|
85
|
+
def,
|
|
86
|
+
optional: () => next({ optional: true }),
|
|
87
|
+
unique: () => next({ unique: true }),
|
|
88
|
+
index: () => next({ index: true }),
|
|
89
|
+
default: (value: unknown) => next({ hasDefault: true, default: value }),
|
|
90
|
+
array: () => next({ array: true }),
|
|
91
|
+
id: () => next({ isId: true }),
|
|
92
|
+
onDelete: (policy) => next({ onDelete: policy }),
|
|
93
|
+
fulltext: (lang = "multi") => next({ fulltext: lang }),
|
|
94
|
+
vector: (size, metric = "cosine") => next({ vector: { size, metric } }),
|
|
95
|
+
} as Field<T, M>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base = (kind: FieldKind, extra: Partial<FieldDef> = {}): FieldDef => ({
|
|
99
|
+
kind, optional: false, unique: false, index: false, hasDefault: false, array: false, isId: false, ...extra,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const scalar = <T, K extends ScalarKind>(scalar: K) =>
|
|
103
|
+
create<T, Meta<{ kind: "scalar", scalar: K }>>(base("scalar", { scalar }))
|
|
104
|
+
|
|
105
|
+
export const t = {
|
|
106
|
+
string: () => scalar<string, "string">("string"),
|
|
107
|
+
/** 64-bit signed integer. */
|
|
108
|
+
int: () => scalar<number, "int">("int"),
|
|
109
|
+
/** 64-bit float. */
|
|
110
|
+
float: () => scalar<number, "float">("float"),
|
|
111
|
+
bool: () => scalar<boolean, "bool">("bool"),
|
|
112
|
+
/** Epoch milliseconds — reads and writes as `number`. */
|
|
113
|
+
date: () => scalar<number, "date">("date"),
|
|
114
|
+
/** UUID string (16 bytes in storage). On an `id` field the runtime generates it on insert. */
|
|
115
|
+
uuid: () => scalar<string, "uuid">("uuid"),
|
|
116
|
+
/** Any JSON value; `J` is TS-only (marcidb stores it schemaless). Filter by dot-path in `$where`. */
|
|
117
|
+
json: <J = JsonValue>() => create<J, Meta<{ kind: "json" }>>(base("json")),
|
|
118
|
+
/** String enum: `t.enum("draft", "published")`. */
|
|
119
|
+
enum: <const V extends readonly [string, ...string[]]>(...values: V) =>
|
|
120
|
+
create<V[number], Meta<{ kind: "enum" }>>(base("enum", { values })),
|
|
121
|
+
/** Owned nested object (marcidb `struct`): created/updated with the parent, no id of its own. */
|
|
122
|
+
struct: <F extends Record<string, Field<any, any>>>(fields: F) =>
|
|
123
|
+
create<F, Meta<{ kind: "struct" }>>(base("struct", { fields })),
|
|
124
|
+
/** Reference to one row of `model` (by id). Required unless `.optional()`. */
|
|
125
|
+
one: <N extends string>(model: N) => create<never, Meta<{ kind: "one", model: N }>>(base("one", { model })),
|
|
126
|
+
/** Reverse side of a `t.one()` on `model` (the field is inferred when unambiguous). Read-only list. */
|
|
127
|
+
many: <N extends string>(model: N, backField?: string) =>
|
|
128
|
+
create<never, Meta<{ kind: "many", model: N }>>(base("many", { model, back: backField })),
|
|
129
|
+
/** Ordered relation list stored inline (`@list`) — hand-arranged collections, duplicates allowed. */
|
|
130
|
+
list: <N extends string>(model: N) => create<never, Meta<{ kind: "list", model: N }>>(base("list", { model })),
|
|
131
|
+
}
|
|
132
|
+
export type T = typeof t
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MarciTransport` over marcidb-server's HTTP API (docs/HTTP-API.md in the marci-db repo) — the
|
|
3
|
+
* transport the runner installs in every project worker (`setDbTransport(marciHttpTransport(url))`).
|
|
4
|
+
* Host-side code (Bun/Node): uses the platform `fetch`, never the SDK bridge. Mirrors marcidb's own
|
|
5
|
+
* `httpTransport` (crates/marcidb-ts/src/prefix.js) minus the binary read fast path.
|
|
6
|
+
*
|
|
7
|
+
* `baseUrl` = `http://host:port/<db>`; an optional bearer token (marcidb-server `MARCI_TOKEN`) is sent
|
|
8
|
+
* on every request. Pass `fetch` explicitly when the global one might be monkey-patched by untrusted
|
|
9
|
+
* code running in the same realm (the runner captures it before evaluating a project bundle).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MarciOp, MarciTransport } from "./marci/query"
|
|
13
|
+
|
|
14
|
+
/** A failed marcidb request: `status` is the HTTP status (0 = network failure), `message` its body. */
|
|
15
|
+
export class MarciHttpError extends Error {
|
|
16
|
+
readonly status: number
|
|
17
|
+
constructor(status: number, message: string) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = "MarciHttpError"
|
|
20
|
+
this.status = status
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MarciHttpOptions = {
|
|
25
|
+
token?: string | null
|
|
26
|
+
fetch?: typeof fetch
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `update/:id` and `delete/:id` take the key in the path: a bare value, or `k=v&k2=v2` for composite keys
|
|
30
|
+
* (nested ids flatten with dots — `{ chat: { id: 1 } }` → `chat.id=1`). */
|
|
31
|
+
export const encodeMarciId = (id: unknown): string => {
|
|
32
|
+
if (typeof id === "object" && id !== null) {
|
|
33
|
+
const parts: string[] = []
|
|
34
|
+
const walk = (obj: Record<string, unknown>, prefix: string) => {
|
|
35
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
36
|
+
const key = prefix ? `${prefix}.${k}` : k
|
|
37
|
+
if (typeof v === "object" && v !== null) walk(v as Record<string, unknown>, key)
|
|
38
|
+
else parts.push(`${key}=${encodeURIComponent(String(v))}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
walk(id as Record<string, unknown>, "")
|
|
42
|
+
return parts.join("&")
|
|
43
|
+
}
|
|
44
|
+
return encodeURIComponent(String(id))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Low-level request helper (also used by the runner's admin calls: `$sync`, `DELETE /:db`). */
|
|
48
|
+
export const marciRequest = async (
|
|
49
|
+
url: string,
|
|
50
|
+
init: { method?: string, body?: unknown, rawBody?: string, token?: string | null, fetch?: typeof fetch } = {},
|
|
51
|
+
): Promise<any> => {
|
|
52
|
+
const f = init.fetch ?? fetch
|
|
53
|
+
const headers: Record<string, string> = {}
|
|
54
|
+
if (init.token) headers.authorization = `Bearer ${init.token}`
|
|
55
|
+
let body: string | undefined
|
|
56
|
+
if (init.rawBody !== undefined) { body = init.rawBody; headers["content-type"] = "text/plain" }
|
|
57
|
+
else if (init.body !== undefined) { body = JSON.stringify(init.body); headers["content-type"] = "application/json" }
|
|
58
|
+
let res: Response
|
|
59
|
+
try {
|
|
60
|
+
res = await f(url, { method: init.method ?? "POST", headers, body })
|
|
61
|
+
} catch (e) {
|
|
62
|
+
throw new MarciHttpError(0, `marcidb unreachable: ${(e as Error)?.message ?? e}`)
|
|
63
|
+
}
|
|
64
|
+
const text = await res.text()
|
|
65
|
+
if (!res.ok) throw new MarciHttpError(res.status, text || `HTTP ${res.status}`)
|
|
66
|
+
if (!text) return undefined
|
|
67
|
+
try { return JSON.parse(text) } catch { return text }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const marciHttpTransport = (baseUrl: string, options: MarciHttpOptions = {}): MarciTransport => {
|
|
71
|
+
const url = baseUrl.replace(/\/+$/, "")
|
|
72
|
+
const call = (path: string, body?: unknown) => marciRequest(`${url}/${path}`, { body, token: options.token, fetch: options.fetch })
|
|
73
|
+
return {
|
|
74
|
+
exec(op: MarciOp) {
|
|
75
|
+
switch (op.action) {
|
|
76
|
+
case "findMany": return call(`${op.model}/findMany`, op.query ?? {})
|
|
77
|
+
case "findFirst": return call(`${op.model}/findFirst`, op.query ?? {})
|
|
78
|
+
case "insert": return call(`${op.model}/insert`, op.data)
|
|
79
|
+
case "update": return call(`${op.model}/update/${encodeMarciId(op.id)}`, op.data).then(() => null)
|
|
80
|
+
// no id in the path — the filter and the payload travel together in the body
|
|
81
|
+
case "updateMany": return call(`${op.model}/updateMany`, { ...(op.query ?? {}), data: op.data })
|
|
82
|
+
case "delete": return call(`${op.model}/delete/${encodeMarciId(op.id)}`).then(() => null)
|
|
83
|
+
case "deleteMany": return call(`${op.model}/deleteMany`, op.query ?? {})
|
|
84
|
+
case "count": return call(`${op.model}/count`, op.query ?? {})
|
|
85
|
+
case "aggregate": return call(`${op.model}/aggregate`, op.query ?? {})
|
|
86
|
+
default: return Promise.reject(new Error(`marcidb: unknown action '${op.action}'`))
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
batch(ops: MarciOp[]) {
|
|
90
|
+
return call("$transaction", ops)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Server-side db layer (phase 0 prototype of docs/backend-plan.md §3.1). Not yet part of inject.ts.
|
|
2
|
+
export { t } from "./fields"
|
|
3
|
+
export type { Field, FieldDef, FieldMeta } from "./fields"
|
|
4
|
+
export { model, defineDb, ref, setDbTransport, toMarci } from "./defineDb"
|
|
5
|
+
export type { DefineDbOptions } from "./defineDb"
|
|
6
|
+
export type { Db, Model, Schema, Row, Id, Insert, Update, Select, Where, Query, QueryObject, Result, Collection, ScalarSelect, Types } from "./types"
|
|
7
|
+
export type { Op, MarciOp, MarciTransport, JsonValue, Sub } from "./marci/query"
|