lecodes-sdk 2.0.4 → 2.0.6
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 +104 -76
- package/dist/global.d.ts +2 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +3 -3
- package/dist/types/runtime/device.d.ts +7 -0
- package/dist/types/runtime/rpc.d.ts +11 -17
- package/dist/types/runtime/wire.d.ts +53 -0
- package/dist/types/server/auth/api.d.ts +42 -0
- package/dist/types/server/auth/appConfig.d.ts +1 -5
- package/dist/types/server/auth/models.d.ts +119 -70
- package/dist/types/server/auth/types.d.ts +19 -43
- package/dist/types/server/channel.d.ts +57 -19
- package/dist/types/server/context.d.ts +2 -2
- package/dist/types/server/db/defineDb.d.ts +10 -0
- package/dist/types/server/db/index.d.ts +1 -1
- package/dist/types/server/db/types.d.ts +76 -6
- package/dist/types/server/inject.d.ts +0 -1
- package/dist/types/ui/UINode.d.ts +19 -5
- package/dist/types/ui/UIScreen.d.ts +1 -0
- package/dist/types/ui/UITabs.d.ts +8 -6
- package/dist/types/ui/theme.d.ts +48 -13
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +3 -2
- package/prompts/README.md +1 -1
- package/prompts/design.md +19 -19
- package/prompts/dist/2d-game.md +45 -31
- package/prompts/dist/3d-app.md +45 -31
- package/prompts/dist/ar-app.md +45 -31
- package/prompts/dist/design.md +25 -24
- package/prompts/dist/ui-app.md +45 -31
- package/prompts/ui-design.md +6 -5
- package/prompts/ui.md +25 -22
- package/src/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- package/src/chisel.ts +1 -1
- package/src/compile/bundler.ts +6 -0
- package/src/compile/compileProject.ts +3 -1
- package/src/compile/index.ts +3 -1
- package/src/compile/serverSplit.ts +58 -11
- package/src/compile/serverTypes.ts +189 -8
- package/src/host.d.ts +3 -0
- package/src/inject.ts +6 -6
- package/src/runtime/device.ts +12 -0
- package/src/runtime/rpc.ts +101 -40
- package/src/runtime/wire.ts +35 -0
- package/src/server/auth/api.ts +94 -0
- package/src/server/auth/appConfig.ts +2 -3
- package/src/server/auth/host.ts +244 -174
- package/src/server/auth/models.ts +45 -62
- package/src/server/auth/types.ts +19 -34
- package/src/server/channel.ts +97 -29
- package/src/server/channelHub.ts +153 -0
- package/src/server/context.ts +2 -2
- package/src/server/db/defineDb.ts +96 -36
- package/src/server/db/index.ts +1 -1
- package/src/server/db/types.ts +76 -8
- package/src/server/host.ts +25 -10
- package/src/server/inject.ts +2 -2
- package/src/server/runtime.ts +34 -12
- package/src/ui/UINode.ts +22 -5
- package/src/ui/UIScreen.ts +5 -0
- package/src/ui/UITabs.ts +19 -17
- package/src/ui/styleColor.ts +10 -1
- package/src/ui/theme.ts +96 -41
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +1 -0
- package/dist/types/plugins/oauth.d.ts +0 -25
- package/dist/types/server/auth/global.d.ts +0 -56
- package/src/plugins/oauth.ts +0 -61
- package/src/server/auth/global.ts +0 -80
- package/tests/helpers/memoryMarci.ts +0 -124
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Field, FieldDef, ScalarKind } from "./fields"
|
|
12
|
-
import {
|
|
12
|
+
import { createAuthApi } from "../auth/api"
|
|
13
|
+
import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityFields, sessionFields } from "../auth/models"
|
|
13
14
|
import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
|
|
14
15
|
import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
|
|
15
16
|
|
|
@@ -138,7 +139,8 @@ const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): st
|
|
|
138
139
|
if (d.isId) attrs.unshift("@id")
|
|
139
140
|
if (d.unique) attrs.push("@unique")
|
|
140
141
|
if (d.index) attrs.push("@index")
|
|
141
|
-
|
|
142
|
+
// an array's default is filled in on insert (`withDefaults`): the engine reads `@default([])` as [""]
|
|
143
|
+
if (d.hasDefault && !d.array) attrs.push(`@default(${literal(d.default, d)})`)
|
|
142
144
|
if (d.onDelete) attrs.push(`@onDelete(${ON_DELETE[d.onDelete]})`)
|
|
143
145
|
if (d.fulltext) attrs.push(`@fulltext(${d.fulltext})`)
|
|
144
146
|
if (d.vector) attrs.push(`@vector(${d.vector.metric})`)
|
|
@@ -190,48 +192,106 @@ export type DefineDbOptions = { transport?: MarciTransport }
|
|
|
190
192
|
/**
|
|
191
193
|
* The schema registry: model name = key (`db.user` ← `User`). Relation strings are checked against the
|
|
192
194
|
* keys both at the type level (`ValidateRefs`) and at runtime.
|
|
195
|
+
*
|
|
196
|
+
* A db is SEALED by its first use — a query, a read of its schema, the host taking it after the file
|
|
197
|
+
* has loaded. Until then `.withAuth({ model })` may still change what it holds: it adds the platform's
|
|
198
|
+
* `Session` and `Identity` models (../auth/models.ts) pointing at the project's model of a user, and
|
|
199
|
+
* gives the db its `auth`. So `withAuth` belongs to the definition, written on `defineDb(...)` itself;
|
|
200
|
+
* on a db that has been used it throws.
|
|
193
201
|
*/
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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}")`)
|
|
202
|
+
export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>, options: DefineDbOptions = {}): Db<S> => {
|
|
203
|
+
const own = declared as Record<string, Model>
|
|
204
|
+
for (const name of [AUTH_SESSION_MODEL, AUTH_IDENTITY_MODEL]) {
|
|
205
|
+
if (name in own) throw new Error(`defineDb: "${name}" is the platform's own model (it comes with .withAuth) — name yours differently`)
|
|
203
206
|
}
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return { models: { ...models, [AUTH_SESSION_MODEL]: session }, auth: { user: user ? AUTH_USER_MODEL : null, session: AUTH_SESSION_MODEL } }
|
|
211
|
-
}
|
|
207
|
+
// What is wrong with the project's own models is said HERE, at the definition, not at the first use:
|
|
208
|
+
// checked against a stand-in for the one platform model they may point at.
|
|
209
|
+
buildMeta({ ...own, [AUTH_SESSION_MODEL]: model({}) })
|
|
210
|
+
|
|
211
|
+
let authModel: string | null = null
|
|
212
|
+
let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null } | null = null
|
|
212
213
|
|
|
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
214
|
const transport = () => {
|
|
217
215
|
const t = options.transport ?? seam.__lecodesDbTransport
|
|
218
216
|
if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
|
|
219
217
|
return t
|
|
220
218
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
models:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
const seal = () => {
|
|
220
|
+
if (sealed) return sealed
|
|
221
|
+
const models: Record<string, Model> = { ...own }
|
|
222
|
+
if (authModel) {
|
|
223
|
+
models[AUTH_SESSION_MODEL] = model(sessionFields(authModel))
|
|
224
|
+
models[AUTH_IDENTITY_MODEL] = model(identityFields(authModel))
|
|
225
|
+
} else {
|
|
226
|
+
for (const [name, m] of Object.entries(own)) {
|
|
227
|
+
for (const [key, f] of Object.entries(m.fields as Fields)) {
|
|
228
|
+
if ((f as Field<any, any>).def.model === AUTH_SESSION_MODEL) {
|
|
229
|
+
throw new Error(`defineDb: ${name}.${key} points at "${AUTH_SESSION_MODEL}" — sessions come with sign-in: defineDb({...}).withAuth({ model: "<your user model>" })`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const metas = buildMeta(models)
|
|
235
|
+
// the defaults the schema does not carry: an array's (a copy per row)
|
|
236
|
+
const arrayDefaults: Record<string, [string, unknown[]][]> = {}
|
|
237
|
+
for (const meta of Object.values(metas)) {
|
|
238
|
+
arrayDefaults[meta.name] = Object.entries(meta.fields)
|
|
239
|
+
.filter(([, d]) => d.array && d.hasDefault && Array.isArray(d.default))
|
|
240
|
+
.map(([key, d]) => [key, d.default as unknown[]])
|
|
241
|
+
}
|
|
242
|
+
const withDefaults = (name: string, data: any) => {
|
|
243
|
+
const missing = arrayDefaults[name].filter(([key]) => data?.[key] === undefined)
|
|
244
|
+
if (!missing.length) return data
|
|
245
|
+
const out = { ...data }
|
|
246
|
+
for (const [key, value] of missing) out[key] = [...value]
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
// marcidb's own query builder over our metadata; the transport is the only host-specific piece
|
|
250
|
+
const { collection } = createQueryLayer({
|
|
251
|
+
models: modelsMeta(metas),
|
|
252
|
+
run: op => transport().exec(op),
|
|
253
|
+
// a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
|
|
254
|
+
prepareInsert: (name, raw) => {
|
|
255
|
+
const data = withDefaults(name, raw)
|
|
256
|
+
return metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
const collections: Record<string, unknown> = {}
|
|
260
|
+
for (const name of Object.keys(own)) collections[name] = collection(name)
|
|
261
|
+
sealed = {
|
|
262
|
+
models, schema: toMarci(metas), collections,
|
|
263
|
+
// the platform's models are reached by role, never as `db.session` / `db.identity`
|
|
264
|
+
auth: authModel ? { model: authModel, user: collection(authModel), session: collection(AUTH_SESSION_MODEL), identity: collection(AUTH_IDENTITY_MODEL) } : null,
|
|
265
|
+
}
|
|
266
|
+
return sealed
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
const db: Record<string, unknown> = {
|
|
229
|
-
$
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
270
|
+
$transaction: async (ops: unknown[]) => { seal(); return transport().batch(ops.map(opOf)) },
|
|
271
|
+
withAuth: ({ model: name }: { model: string }) => {
|
|
272
|
+
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
|
+
if (authModel) throw new Error("defineDb(...).withAuth(): called twice on one db")
|
|
274
|
+
const user = own[name]
|
|
275
|
+
if (!user) throw new Error(`defineDb(...).withAuth(): "${name}" is not a model of this db`)
|
|
276
|
+
for (const [key, f] of Object.entries(user.fields as Fields)) {
|
|
277
|
+
const d = (f as Field<any, any>).def
|
|
278
|
+
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
|
|
280
|
+
throw new Error(`defineDb(...).withAuth(): ${name}.${key} needs .optional() or .default(...) — the platform creates the row the first time someone signs in`)
|
|
281
|
+
}
|
|
282
|
+
authModel = name
|
|
283
|
+
db.auth = createAuthApi(() => seal().auth!)
|
|
284
|
+
return db
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
Object.defineProperties(db, {
|
|
288
|
+
$schema: { get: () => seal().schema },
|
|
289
|
+
$models: { get: () => seal().models },
|
|
290
|
+
$auth: { get: () => seal().auth },
|
|
291
|
+
})
|
|
292
|
+
for (const name of Object.keys(own)) {
|
|
293
|
+
Object.defineProperty(db, uncap(name), { enumerable: true, get: () => seal().collections[name] })
|
|
233
294
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return db as Db<S>
|
|
295
|
+
;(seam.__lecodesDbs ??= []).push(db as unknown as Db<any>)
|
|
296
|
+
return db as unknown as Db<S>
|
|
237
297
|
}
|
package/src/server/db/index.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { t } from "./fields"
|
|
|
3
3
|
export type { Field, FieldDef, FieldMeta } from "./fields"
|
|
4
4
|
export { model, defineDb, ref, setDbTransport, toMarci } from "./defineDb"
|
|
5
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"
|
|
6
|
+
export type { Auth, Db, DbWithAuth, UserQuery, Model, Schema, Row, Id, Insert, Update, Select, Where, Query, QueryObject, Result, Collection, ScalarSelect, Types } from "./types"
|
|
7
7
|
export type { Op, MarciOp, MarciTransport, JsonValue, Sub } from "./marci/query"
|
package/src/server/db/types.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Field, FieldMeta } from "./fields"
|
|
11
|
+
import type { IdentityPublicFields, SessionPublicFields } from "../auth/models"
|
|
11
12
|
import type {
|
|
12
13
|
CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearchValue,
|
|
13
14
|
FullTextSearch, JsonCondition, JsonPathWhere, Op, PrimitiveListUpdate, Query as MarciQuery, RefListUpdate,
|
|
@@ -119,7 +120,7 @@ type FieldWhere<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
|
|
|
119
120
|
: M["kind"] extends "many" | "list" ? CompareRefListValue<Where<S, ModelName<S, Fld>>>
|
|
120
121
|
: M["kind"] extends "struct" ? CompareRefValue<WhereValue<StructWhereFields<S, StructFieldsOf<Fld>>> | null>
|
|
121
122
|
: M["kind"] extends "json" ? JsonPathWhere | JsonCondition
|
|
122
|
-
: M["array"] extends true ?
|
|
123
|
+
: M["array"] extends true ? TsOf<Fld>[] // the whole array, equal — the engine has no filter by an item
|
|
123
124
|
: ScalarWhere<TsOf<Fld>, M> | CustomSearchValue<FullTextSearch> | CustomSearchValue<VectorSearch>
|
|
124
125
|
|
|
125
126
|
type WhereFieldsOf<S, F extends Fields> = { [K in keyof F]?: FieldWhere<S, F[K]> }
|
|
@@ -127,7 +128,11 @@ type IdWhere<S, N extends keyof S> = { [K in keyof Id<S, N> as K extends keyof F
|
|
|
127
128
|
export type Where<S, N extends keyof S> = WhereValue<WhereFieldsOf<S, FieldsOf<S[N]>> & IdWhere<S, N>>
|
|
128
129
|
type StructWhereFields<S, F extends Fields> = WhereFieldsOf<S, F>
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
type OrderKeys<S, N extends keyof S> = (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>
|
|
132
|
+
/** ONE field and its direction: the engine sorts by a single field, so a second key is a type error. */
|
|
133
|
+
export type Order<S, N extends keyof S> = {
|
|
134
|
+
[K in OrderKeys<S, N>]: { [P in K]: "asc" | "desc" } & { [P in Exclude<OrderKeys<S, N>, K>]?: never }
|
|
135
|
+
}[OrderKeys<S, N>]
|
|
131
136
|
|
|
132
137
|
/** The query object — marcidb's wire form: a select shape plus the `$`-clauses. */
|
|
133
138
|
export type QueryObject<S, N extends keyof S> = Select<S, N> & {
|
|
@@ -177,26 +182,89 @@ export type Result<S, N extends keyof S, Q> = Rows<Types<S, N>, Q>
|
|
|
177
182
|
/** `db.<model>` — the root query of a model. */
|
|
178
183
|
export type Collection<S, N extends keyof S> = Query<S, N>
|
|
179
184
|
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
/** The platform's models a project's own may point at (`t.one("Session")`) — see ../auth/models.ts. */
|
|
186
|
+
type PlatformModels = { Session: Model<SessionPublicFields> }
|
|
187
|
+
/** What a db's queries are typed against: the project's models and what they may point at. */
|
|
188
|
+
type Full<S> = S & PlatformModels
|
|
189
|
+
|
|
190
|
+
type DbBase<S extends Schema> = {
|
|
191
|
+
[N in keyof S & string as Uncapitalize<N>]: Query<Full<S>, N>
|
|
182
192
|
} & {
|
|
183
193
|
/** Atomic batch: all ops commit or none. `ref("0.id")` feeds a generated id into a later op. */
|
|
184
194
|
$transaction<P extends readonly Op<any>[]>(ops: [...P]): Promise<{ [K in keyof P]: P[K] extends Op<infer T> ? T : never }>
|
|
185
195
|
/** The `.marci` schema text this db syncs with. */
|
|
186
196
|
readonly $schema: string
|
|
187
197
|
/** @internal */ readonly $models: Record<string, Model>
|
|
188
|
-
/** @internal
|
|
189
|
-
readonly $auth: AuthBinding
|
|
198
|
+
/** @internal the collections sign-in works on; null = a db without `withAuth`. */
|
|
199
|
+
readonly $auth: AuthBinding | null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type Db<S extends Schema> = DbBase<S> & {
|
|
203
|
+
/**
|
|
204
|
+
* Sign-in for this database: `model` is the project's model of a user. Adds the platform's `Session`
|
|
205
|
+
* and `Identity` models beside it and gives the db its `auth`. Part of the definition — call it on
|
|
206
|
+
* `defineDb(...)` itself, before the db is used. Every field of the user model must be optional or
|
|
207
|
+
* have a default: the platform creates the row the first time someone signs in.
|
|
208
|
+
*/
|
|
209
|
+
withAuth<N extends keyof S & string>(options: { model: N }): DbWithAuth<S, N>
|
|
190
210
|
}
|
|
211
|
+
export type DbWithAuth<S extends Schema, N extends keyof S & string> = DbBase<S> & { readonly auth: Auth<S, N> }
|
|
212
|
+
|
|
191
213
|
/** @internal */
|
|
192
|
-
export type AuthBinding = {
|
|
214
|
+
export type AuthBinding = { model: string, user: any, session: any, identity: any }
|
|
215
|
+
|
|
216
|
+
// ───────────────────────────── auth ─────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The user of the current request, as a query over the project's user model: `select(shape)` picks the
|
|
220
|
+
* fields (id + scalars without it), `await` runs it. Nothing happens until it is awaited — a query that
|
|
221
|
+
* is never awaited checks nothing, which is why the compiler refuses one.
|
|
222
|
+
*/
|
|
223
|
+
export interface UserQuery<S, N extends keyof S, Sel, Nil> extends PromiseLike<Result<S, N, Sel> | Nil> {
|
|
224
|
+
select<Q extends Select<S, N>>(shape: Q): UserQuery<S, N, Q, Nil>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type AuthTables = { Session: Model<SessionPublicFields>, Identity: Model<IdentityPublicFields> }
|
|
228
|
+
|
|
229
|
+
/** `db.auth` — see ../auth/api.ts. */
|
|
230
|
+
export type Auth<S extends Schema, N extends keyof S & string> = {
|
|
231
|
+
/** The signed-in user, or `null` for a guest. */
|
|
232
|
+
user(): UserQuery<Full<S>, N, ScalarSelect<Full<S>, N>, null>
|
|
233
|
+
/**
|
|
234
|
+
* The signed-in user; a guest is a 401. With `where` it is a check of access too: a user the filter
|
|
235
|
+
* does not match is a 403 — `await db.auth.requireUser({ role: "admin" }).select({ id: true })`.
|
|
236
|
+
*/
|
|
237
|
+
requireUser(where?: Where<Full<S>, N>): UserQuery<Full<S>, N, ScalarSelect<Full<S>, N>, never>
|
|
238
|
+
/** The device's session — there from the first request, before any sign-in (a guest's cart points at it). */
|
|
239
|
+
readonly sessionId: number
|
|
240
|
+
|
|
241
|
+
/** A new account: `login` is an email or a name (3–32 letters, digits, `. _ -`), `data` the fields of the
|
|
242
|
+
* user's row. 409 when the login is taken. Signs this device in. */
|
|
243
|
+
signUpWithPassword(login: string, password: string, data?: Insert<Full<S>, N>): Promise<void>
|
|
244
|
+
/** Signs this device in; 401 on any failure (rate-limited per login and ip). */
|
|
245
|
+
signInWithPassword(login: string, password: string): Promise<void>
|
|
246
|
+
/** Sets the current user's password; every other device is signed out. */
|
|
247
|
+
setPassword(password: string): Promise<void>
|
|
248
|
+
/** Emails a 6-digit code (always resolves — no "email exists" oracle; rate-limited). */
|
|
249
|
+
sendCode(email: string): Promise<void>
|
|
250
|
+
/** Checks the code sent to this device, finds or creates the user, signs this device in. */
|
|
251
|
+
signInWithCode(email: string, code: string): Promise<void>
|
|
252
|
+
/** For a person the project verified itself (a provider's sign-in, Telegram initData, SSO): binds this device to the user. */
|
|
253
|
+
signIn(userId: number): Promise<void>
|
|
254
|
+
/** The device stays, as a guest. */
|
|
255
|
+
signOut(): Promise<void>
|
|
256
|
+
/** The current user's devices, as a query: `await`, `.select()`, `.where(...).updateMany({ revoked: true })`. */
|
|
257
|
+
readonly sessions: Query<AuthTables, "Session">
|
|
258
|
+
/** How the current user signs in, as a query: provider (`"email"` | `"login"`), subject, verifiedAt. */
|
|
259
|
+
readonly identities: Query<AuthTables, "Identity">
|
|
260
|
+
}
|
|
193
261
|
|
|
194
262
|
// ───────────────────────────── relation-name validation ─────────────────────────────
|
|
195
263
|
|
|
196
264
|
type RelKinds = "one" | "many" | "list"
|
|
197
265
|
type CheckField<S, Fld> =
|
|
198
266
|
MetaOf<Fld>["kind"] extends RelKinds
|
|
199
|
-
? MetaOf<Fld>["model"] extends keyof S ? Fld : `Unknown model "${MetaOf<Fld>["model"]}" — not a key of defineDb({...})`
|
|
267
|
+
? MetaOf<Fld>["model"] extends keyof S | keyof PlatformModels ? Fld : `Unknown model "${MetaOf<Fld>["model"]}" — not a key of defineDb({...})`
|
|
200
268
|
: Fld
|
|
201
269
|
/** Intersected with `S` in `defineDb`'s parameter: an unknown relation target fails to type-check on that field. */
|
|
202
270
|
export type ValidateRefs<S extends Schema> = { [N in keyof S]: { readonly fields: { [K in keyof FieldsOf<S[N]>]: CheckField<S, FieldsOf<S[N]>[K]> } } }
|
package/src/server/host.ts
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
|
-
loadServerModules, createInvoker,
|
|
9
|
+
loadServerModules, createInvoker, createSubscribeGuard, setChannelPublisher, setRequestProvider,
|
|
10
10
|
} from "./runtime"
|
|
11
11
|
export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration } from "./runtime"
|
|
12
12
|
export type { RequestContext } from "./context"
|
|
13
|
+
export { createChannelHub } from "./channelHub"
|
|
14
|
+
export type { ChannelHub, ChannelHubOptions, HubConnection, HubFanout } from "./channelHub"
|
|
15
|
+
export type { ChannelGroup, ClientFrame, ServerFrame } from "../runtime/wire"
|
|
13
16
|
export { setDbTransport } from "./db/defineDb"
|
|
14
17
|
export { marciHttpTransport, marciRequest, encodeMarciId, MarciHttpError } from "./db/httpTransport"
|
|
15
18
|
export type { MarciHttpOptions } from "./db/httpTransport"
|
|
@@ -17,10 +20,12 @@ export type { MarciOp, MarciTransport } from "./db/marci/query"
|
|
|
17
20
|
export type { ServerManifest, ServerExport } from "../compile/serverSplit"
|
|
18
21
|
export { validateArgs, ValidationError } from "./validate"
|
|
19
22
|
export type { ParamSchema, Schema as ParamSchemaNode } from "./validate"
|
|
20
|
-
export { createAuthHost,
|
|
23
|
+
export { createAuthHost, sha256, hashPassword, verifyPassword } from "./auth/host"
|
|
21
24
|
export type { AuthHost, AuthHostConfig, AuthMail } from "./auth/host"
|
|
22
|
-
export type {
|
|
23
|
-
export type {
|
|
25
|
+
export type { AuthState, AuthOps } from "./auth/types"
|
|
26
|
+
export type { AppConfig } from "./auth/appConfig"
|
|
27
|
+
export type { IdentityProvider } from "./auth/models"
|
|
28
|
+
import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityPublicFields, sessionPublicFields } from "./auth/models"
|
|
24
29
|
|
|
25
30
|
import type { LoadedServer } from "./runtime"
|
|
26
31
|
import type { Field, FieldDef } from "./db/fields"
|
|
@@ -35,11 +40,14 @@ export type FieldDescription = Omit<FieldDef, "fields" | "default"> & { fields?:
|
|
|
35
40
|
export type ServerDescription = {
|
|
36
41
|
/** `.marci` schema text ("" when the project defines no db). */
|
|
37
42
|
marci: string
|
|
38
|
-
|
|
43
|
+
/** `auth: true` = one of the platform's two models (`Session`, `Identity` — they come with
|
|
44
|
+
* `withAuth`), described WITHOUT its secrets (token and password hashes, a pending code): what a
|
|
45
|
+
* data browser may show, read-only. */
|
|
46
|
+
models: Record<string, { fields: Record<string, FieldDescription>, auth?: true }>
|
|
39
47
|
endpoints: { id: string, params: unknown[] | null }[]
|
|
40
48
|
channels: string[]
|
|
41
|
-
/**
|
|
42
|
-
auth: {
|
|
49
|
+
/** The project's model of a user (`null` = its db has no sign-in). */
|
|
50
|
+
auth: { model: string | null }
|
|
43
51
|
/** `app.json` name (when the compile embedded one). */
|
|
44
52
|
appName?: string
|
|
45
53
|
}
|
|
@@ -55,11 +63,18 @@ const describeField = (f: Field<any, any>): FieldDescription => {
|
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export const describeServer = (server: LoadedServer): ServerDescription => {
|
|
58
|
-
|
|
66
|
+
// normally one db; with several, the one with sign-in speaks for the project
|
|
67
|
+
const db = server.dbs.find(d => d.$auth) ?? server.dbs[0]
|
|
59
68
|
const models: ServerDescription["models"] = {}
|
|
60
69
|
if (db) {
|
|
70
|
+
// what of the platform's models is not a secret: their public fields, and whose row it is
|
|
71
|
+
const shown: Record<string, string[]> = db.$auth ? {
|
|
72
|
+
[AUTH_SESSION_MODEL]: [...Object.keys(sessionPublicFields()), "user"],
|
|
73
|
+
[AUTH_IDENTITY_MODEL]: [...Object.keys(identityPublicFields()), "user"],
|
|
74
|
+
} : {}
|
|
61
75
|
for (const [name, m] of Object.entries(db.$models as Record<string, Model>)) {
|
|
62
|
-
|
|
76
|
+
const fields = Object.entries(m.fields as Record<string, Field<any, any>>).filter(([k]) => !shown[name] || shown[name].includes(k))
|
|
77
|
+
models[name] = { fields: Object.fromEntries(fields.map(([k, f]) => [k, describeField(f)])), ...(shown[name] ? { auth: true as const } : {}) }
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
const params = (server.manifest.params ?? {}) as Record<string, unknown[]>
|
|
@@ -68,7 +83,7 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
|
|
|
68
83
|
models,
|
|
69
84
|
endpoints: [...server.endpoints.keys()].map(id => ({ id, params: params[id] ?? null })),
|
|
70
85
|
channels: [...server.channels.keys()],
|
|
71
|
-
auth: {
|
|
86
|
+
auth: { model: db?.$auth?.model ?? null },
|
|
72
87
|
appName: server.manifest.app?.name,
|
|
73
88
|
}
|
|
74
89
|
}
|
package/src/server/inject.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* chisel injects these into the server bundle exactly like ./inject.ts does for the client bundle;
|
|
4
4
|
* nothing here may reach the client bundle. Keep this the ONLY file that exports server globals.
|
|
5
5
|
*
|
|
6
|
-
* Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, request
|
|
6
|
+
* Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, request. Sign-in is not a
|
|
7
|
+
* global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
export { t, model, defineDb, ref } from "./db"
|
|
10
11
|
export { ApiError } from "./errors"
|
|
11
12
|
export { channel } from "./channel"
|
|
12
13
|
export { request } from "./context"
|
|
13
|
-
export { auth } from "./auth/global"
|
package/src/server/runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* evaluate the bundle (it sets globalThis.__lecodesServer)
|
|
7
7
|
* const server = loadServerModules() // classify exports, name channels
|
|
8
8
|
* const invoke = createInvoker(server) // validate → run in request context → envelope
|
|
9
|
+
* const subscribe = createSubscribeGuard(server) // a channel's hooks → the subscriber's group
|
|
9
10
|
* await invoke("notes.server.ts#listNotes", [20], { headers, ip })
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -13,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
|
|
|
13
14
|
import type { ServerManifest } from "../compile/serverSplit"
|
|
14
15
|
import type { AuthHost } from "./auth/host"
|
|
15
16
|
import type { AuthState } from "./auth/types"
|
|
16
|
-
import { setChannelPublisher, type
|
|
17
|
+
import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
|
|
17
18
|
import { setRequestProvider, type RequestContext } from "./context"
|
|
18
19
|
import type { Db } from "./db/types"
|
|
19
20
|
import { takeRegisteredDbs } from "./db/defineDb"
|
|
@@ -23,14 +24,13 @@ export type ServerRegistration = { modules: Record<string, Record<string, unknow
|
|
|
23
24
|
|
|
24
25
|
export type LoadedServer = {
|
|
25
26
|
endpoints: Map<string, (...args: any[]) => unknown>
|
|
26
|
-
channels: Map<string,
|
|
27
|
-
/**
|
|
27
|
+
channels: Map<string, ChannelRecord>
|
|
28
|
+
/** The project's `defineDb` handle — exported or not (the bundle registers it on globalThis); none or one. */
|
|
28
29
|
dbs: Db<any>[]
|
|
29
30
|
manifest: ServerManifest
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const isDb = (v: unknown): v is Db<any> => typeof v === "object" && v !== null && typeof (v as Db<any>).$schema === "string"
|
|
33
|
-
const isChannel = (v: unknown): v is Channel<any> => typeof v === "object" && v !== null && (v as Channel<any>).__channel === true
|
|
34
34
|
|
|
35
35
|
/** Read the registration a server bundle left on `globalThis.__lecodesServer` (or a given one). */
|
|
36
36
|
export const loadServerModules = (
|
|
@@ -48,6 +48,12 @@ export const loadServerModules = (
|
|
|
48
48
|
}
|
|
49
49
|
// Non-exported handles (`const db = defineDb(...)`): the bundle's SDK copy registered them on globalThis.
|
|
50
50
|
for (const db of takeRegisteredDbs()) if (!out.dbs.includes(db)) out.dbs.push(db)
|
|
51
|
+
// One database per project: the host syncs one schema, so the models of a second `defineDb` would not exist.
|
|
52
|
+
if (out.dbs.length > 1) {
|
|
53
|
+
// named by their collections (own enumerable keys): reading `$models` would seal a db that is being refused
|
|
54
|
+
const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth").map(k => `db.${k}`).join(", ")).join(" | ")
|
|
55
|
+
throw new Error(`a project has one database, and defineDb is called ${out.dbs.length} times (${each}) — put every model in one defineDb({...}) and import that db where it is needed`)
|
|
56
|
+
}
|
|
51
57
|
return out
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -72,9 +78,9 @@ const apiStatus = (e: unknown): number | null =>
|
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
80
|
* Build the invoker: `invoke(id, args, ctx)` runs endpoint `id` inside an AsyncLocalStorage-scoped
|
|
75
|
-
* request context (what the `request
|
|
81
|
+
* request context (what the `request` global and `db.auth` read) and returns the wire envelope.
|
|
76
82
|
*/
|
|
77
|
-
// One request store for endpoints AND channel
|
|
83
|
+
// One request store for endpoints AND channel hooks (a second store would steal the provider).
|
|
78
84
|
const als = new AsyncLocalStorage<RequestContext>()
|
|
79
85
|
const installProvider = () => setRequestProvider(() => als.getStore())
|
|
80
86
|
|
|
@@ -111,21 +117,37 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
|
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
120
|
+
/**
|
|
121
|
+
* A subscription to a channel: run its hooks (`authorize`, then `groupBy`) in the subscriber's
|
|
122
|
+
* request scope. The envelope's `result` is the group the subscriber is in — `null` for a channel
|
|
123
|
+
* without groups. `args` come from the client and are validated like an endpoint's.
|
|
124
|
+
*/
|
|
125
|
+
export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions = {}) => {
|
|
116
126
|
installProvider()
|
|
127
|
+
const validate = opts.validate ?? true
|
|
128
|
+
const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
|
|
129
|
+
const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
|
|
117
130
|
const auth = opts.auth ?? null
|
|
118
|
-
return async (channelId: string,
|
|
131
|
+
return async (channelId: string, args: unknown[] = [], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
|
|
119
132
|
const ch = server.channels.get(channelId)
|
|
120
133
|
if (!ch) return { ok: false, status: 404, message: `Unknown channel ${channelId}` }
|
|
134
|
+
if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
|
|
121
135
|
const rc: RequestContext = { ...ctx, id: channelId }
|
|
122
136
|
try {
|
|
137
|
+
if (validate && ch.__groupBy && params?.[channelId]) validateArgs(params[channelId], args)
|
|
123
138
|
if (auth) await auth.resolve(rc)
|
|
124
|
-
await als.run(rc, () =>
|
|
125
|
-
|
|
139
|
+
const group = await als.run(rc, async () => {
|
|
140
|
+
await ch.__authorize?.()
|
|
141
|
+
return ch.__groupBy ? await ch.__groupBy(...args) : null
|
|
142
|
+
})
|
|
143
|
+
if (group !== null && !isChannelGroup(group)) throw new Error(`groupBy answered ${group === undefined ? "nothing" : typeof group} — a group is a string or a number`)
|
|
144
|
+
return withSession({ ok: true, result: group }, rc)
|
|
126
145
|
} catch (e) {
|
|
127
146
|
const status = apiStatus(e)
|
|
128
|
-
return withSession({ ok: false, status
|
|
147
|
+
if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
|
|
148
|
+
if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
|
|
149
|
+
onError(channelId, e)
|
|
150
|
+
return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
}
|
package/src/ui/UINode.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { FetchResponse } from "../runtime/fetch"
|
|
2
|
+
import type { SvgSourceValue } from "../runtime/misc"
|
|
2
3
|
import type { ColorInput } from "../core/color"
|
|
3
4
|
import { createBinding, disposeBinding } from "../core/signals"
|
|
4
5
|
import type { Animation } from "../animate/tween/Animation"
|
|
5
6
|
import { CLOCK_UI, DOM_UI, KIND_DISCRETE, KIND_TRANSFORM, KIND_TRANSFORM_MATRIX, parseTransformList, uiValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
|
|
6
7
|
import { tweenBag } from "../animate/tween/Timeline"
|
|
7
8
|
import { wireStyle, wireValue } from "./styleColor"
|
|
9
|
+
import { _setNodeTheme, type ThemeRoles } from "./theme"
|
|
8
10
|
import { DEAD_HANDLE, TREE_FLAG_LAYOUT, elementsOf, ensureEmitter, pin, tree, unpin, type TreeElement } from "./tree"
|
|
9
11
|
|
|
10
12
|
// The UI element model since phase 9 (docs/plans/hosts-unification-plan.md): the runtime OWNS the tree —
|
|
@@ -31,7 +33,7 @@ export type UINodeChild = UINode | null | undefined | false
|
|
|
31
33
|
export type UIChildArg = UINodeChild | UINodeChild[]
|
|
32
34
|
|
|
33
35
|
/** A color: any CSS color string (`"#1c1c1e"`, `"rgba(0,0,0,0.5)"`, `"green"`, `"hsl(210 50% 40%)"`),
|
|
34
|
-
* a theme reference (`"var(--
|
|
36
|
+
* a theme reference (`"var(--accent)"`), an opaque `0xRRGGBB` number or `[r, g, b(, a)]` in
|
|
35
37
|
* 0..1; `null` clears. A string is the core's to parse (an unknown one is transparent, as in
|
|
36
38
|
* CSS); a number or an array that is not a color throws at the write. */
|
|
37
39
|
export type Color = ColorInput | null
|
|
@@ -52,12 +54,13 @@ export type BaseStyle = {
|
|
|
52
54
|
|
|
53
55
|
/** Painted-box props: background, gradient, border, radius. */
|
|
54
56
|
export type DrawableStyle = {
|
|
55
|
-
backgroundImage?: FetchResponse | File | string | null,
|
|
57
|
+
backgroundImage?: FetchResponse | File | SvgSourceValue | string | null,
|
|
56
58
|
bgGradient?: string,
|
|
57
59
|
backgroundGradient?: string,
|
|
58
|
-
bgImage?: FetchResponse
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
bgImage?: FetchResponse | File | SvgSourceValue | string | null,
|
|
61
|
+
/** How the image fills the box. "tile": repeated at its own size from the top-left corner. */
|
|
62
|
+
backgroundSize?: "cover" | "contain" | "fill" | "tile",
|
|
63
|
+
bgSize?: "cover" | "contain" | "fill" | "tile",
|
|
61
64
|
border?: string | number | null,
|
|
62
65
|
borderWidth?: number,
|
|
63
66
|
borderColor?: Color,
|
|
@@ -258,6 +261,16 @@ export interface UIElementBase<S extends object, A extends object = S> {
|
|
|
258
261
|
name?: string
|
|
259
262
|
/** Set the semantic name (chainable). */
|
|
260
263
|
named(name: string): this
|
|
264
|
+
/** Theme variables for THIS element's subtree (chainable): the same keys as the app-wide
|
|
265
|
+
* `theme()`, read by everything under the element — and by the kit components there — before
|
|
266
|
+
* the app's table. `null` takes a key out of the scope. Reaches the element's descendants in
|
|
267
|
+
* the tree (a pager's pages and a list's items included); a screen pushed over it and a widget
|
|
268
|
+
* are roots of their own. Re-calling restyles the live subtree.
|
|
269
|
+
* ```ts
|
|
270
|
+
* promo.theme({ accent: "#FF3B30" }) // one card in another accent
|
|
271
|
+
* sheet.theme({ bg: "#000", text: "#FFF" }) // a dark screen in a light app
|
|
272
|
+
* ``` */
|
|
273
|
+
theme(values: ThemeRoles & Record<string, string | number | null>): this
|
|
261
274
|
/** Tween to the target style — meta keys `duration`/`delay`/`loop`/…, see {@link AnimateStyle}. */
|
|
262
275
|
animateTo: AnimateStyle<this, A>
|
|
263
276
|
/** Tween from the given style to the current one (entrance animations). */
|
|
@@ -531,6 +544,10 @@ export class Element<T extends string> implements TreeElement {
|
|
|
531
544
|
this.name = name
|
|
532
545
|
return this
|
|
533
546
|
}
|
|
547
|
+
theme(values: Record<string, string | number | null>): this {
|
|
548
|
+
_setNodeTheme(this._h.id, values)
|
|
549
|
+
return this
|
|
550
|
+
}
|
|
534
551
|
|
|
535
552
|
get style(): any {
|
|
536
553
|
if (this._styleProxy) return this._styleProxy
|
package/src/ui/UIScreen.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface UIScreen extends UIContainerBase<UIScreenStyle, DrawableStyle &
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export class ScreenElement extends ContainerElement<"screen"> {
|
|
64
|
+
// The per-type default: a screen's background is the theme's `bg` (no theme = none, the host's).
|
|
65
|
+
constructor(type: "screen", style: any, children: any) {
|
|
66
|
+
super(type, { bgColor: "var(--bg)", ...style }, children)
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
/** @internal */
|
|
65
70
|
_dest(): DestTuple { return [TREE_DEST_SCREEN, this._h.id, "", ""] }
|
|
66
71
|
|