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.
Files changed (72) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +2 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +3 -3
  5. package/dist/types/runtime/device.d.ts +7 -0
  6. package/dist/types/runtime/rpc.d.ts +11 -17
  7. package/dist/types/runtime/wire.d.ts +53 -0
  8. package/dist/types/server/auth/api.d.ts +42 -0
  9. package/dist/types/server/auth/appConfig.d.ts +1 -5
  10. package/dist/types/server/auth/models.d.ts +119 -70
  11. package/dist/types/server/auth/types.d.ts +19 -43
  12. package/dist/types/server/channel.d.ts +57 -19
  13. package/dist/types/server/context.d.ts +2 -2
  14. package/dist/types/server/db/defineDb.d.ts +10 -0
  15. package/dist/types/server/db/index.d.ts +1 -1
  16. package/dist/types/server/db/types.d.ts +76 -6
  17. package/dist/types/server/inject.d.ts +0 -1
  18. package/dist/types/ui/UINode.d.ts +19 -5
  19. package/dist/types/ui/UIScreen.d.ts +1 -0
  20. package/dist/types/ui/UITabs.d.ts +8 -6
  21. package/dist/types/ui/theme.d.ts +48 -13
  22. package/dist/types/version.d.ts +1 -1
  23. package/dist/types.json +1 -1
  24. package/package.json +3 -2
  25. package/prompts/README.md +1 -1
  26. package/prompts/design.md +19 -19
  27. package/prompts/dist/2d-game.md +45 -31
  28. package/prompts/dist/3d-app.md +45 -31
  29. package/prompts/dist/ar-app.md +45 -31
  30. package/prompts/dist/design.md +25 -24
  31. package/prompts/dist/ui-app.md +45 -31
  32. package/prompts/ui-design.md +6 -5
  33. package/prompts/ui.md +25 -22
  34. package/src/bridges/device.d.ts +9 -0
  35. package/src/bridges/tree.d.ts +5 -0
  36. package/src/chisel.ts +1 -1
  37. package/src/compile/bundler.ts +6 -0
  38. package/src/compile/compileProject.ts +3 -1
  39. package/src/compile/index.ts +3 -1
  40. package/src/compile/serverSplit.ts +58 -11
  41. package/src/compile/serverTypes.ts +189 -8
  42. package/src/host.d.ts +3 -0
  43. package/src/inject.ts +6 -6
  44. package/src/runtime/device.ts +12 -0
  45. package/src/runtime/rpc.ts +101 -40
  46. package/src/runtime/wire.ts +35 -0
  47. package/src/server/auth/api.ts +94 -0
  48. package/src/server/auth/appConfig.ts +2 -3
  49. package/src/server/auth/host.ts +244 -174
  50. package/src/server/auth/models.ts +45 -62
  51. package/src/server/auth/types.ts +19 -34
  52. package/src/server/channel.ts +97 -29
  53. package/src/server/channelHub.ts +153 -0
  54. package/src/server/context.ts +2 -2
  55. package/src/server/db/defineDb.ts +96 -36
  56. package/src/server/db/index.ts +1 -1
  57. package/src/server/db/types.ts +76 -8
  58. package/src/server/host.ts +25 -10
  59. package/src/server/inject.ts +2 -2
  60. package/src/server/runtime.ts +34 -12
  61. package/src/ui/UINode.ts +22 -5
  62. package/src/ui/UIScreen.ts +5 -0
  63. package/src/ui/UITabs.ts +19 -17
  64. package/src/ui/styleColor.ts +10 -1
  65. package/src/ui/theme.ts +96 -41
  66. package/src/version.ts +1 -1
  67. package/tests/helpers/fakeTree.ts +1 -0
  68. package/dist/types/plugins/oauth.d.ts +0 -25
  69. package/dist/types/server/auth/global.d.ts +0 -56
  70. package/src/plugins/oauth.ts +0 -61
  71. package/src/server/auth/global.ts +0 -80
  72. 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 { AUTH_SESSION_MODEL, AUTH_USER_MODEL, authSessionModel, isAuthModel, sessionUserField } from "../auth/models"
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
- if (d.hasDefault) attrs.push(`@default(${literal(d.default, d)})`)
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
- * 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}")`)
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
- // 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
- }
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
- // 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
- })
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
- $schema: toMarci(metas),
230
- $models: models,
231
- $auth: auth,
232
- $transaction: async (ops: unknown[]) => transport().batch(ops.map(opOf)),
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
- 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>
295
+ ;(seam.__lecodesDbs ??= []).push(db as unknown as Db<any>)
296
+ return db as unknown as Db<S>
237
297
  }
@@ -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"
@@ -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 ? CompareValue<TsOf<Fld>>
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
- export type Order<S, N extends keyof S> = { [K in (ScalarKeys<FieldsOf<S[N]>> | keyof Id<S, N>) & keyof Row<S, N>]?: "asc" | "desc" }
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
- export type Db<S extends Schema> = {
181
- [N in keyof S & string as Uncapitalize<N>]: Query<S, N>
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 which models the auth runtime binds to (`Session` always exists; `User` when declared). */
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 = { user: string | null, session: string }
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]> } } }
@@ -6,10 +6,13 @@
6
6
  */
7
7
 
8
8
  export {
9
- loadServerModules, createInvoker, createJoinGuard, setChannelPublisher, setRequestProvider,
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, signNonce, verifyNonce, sha256 } from "./auth/host"
23
+ export { createAuthHost, sha256, hashPassword, verifyPassword } from "./auth/host"
21
24
  export type { AuthHost, AuthHostConfig, AuthMail } from "./auth/host"
22
- export type { AuthUser, AuthState, AuthOps, OAuthProfile, OAuthCredential } from "./auth/types"
23
- export type { AuthProvider, AppConfig } from "./auth/appConfig"
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
- models: Record<string, { fields: Record<string, FieldDescription> }>
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
- /** Which models the auth runtime binds to (`null` = the project has no db) and the app.json providers. */
42
- auth: { user: string | null, session: string | null, providers: string[] }
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
- const db = server.dbs[0]
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
- models[name] = { fields: Object.fromEntries(Object.entries(m.fields as Record<string, Field<any, any>>).map(([k, f]) => [k, describeField(f)])) }
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: { user: db?.$auth.user ?? null, session: db?.$auth.session ?? null, providers: server.manifest.app?.auth?.providers ?? [] },
86
+ auth: { model: db?.$auth?.model ?? null },
72
87
  appName: server.manifest.app?.name,
73
88
  }
74
89
  }
@@ -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, auth.
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"
@@ -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 Channel } from "./channel"
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, Channel<any>>
27
- /** Every `defineDb` handle — exported or not (the bundle registers them on globalThis; normally exactly one). */
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`/`auth` globals read) and returns the wire envelope.
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 guards (a second store would steal the provider).
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
- /** Run a channel's `onJoin` guard for a subscription (same request scoping as endpoints). */
115
- export const createJoinGuard = (server: LoadedServer, opts: Pick<InvokerOptions, "auth"> = {}) => {
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, topic: string, ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
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, () => ch.options.onJoin?.(topic))
125
- return withSession({ ok: true, result: null }, rc)
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: status ?? 500, message: status !== null ? (e as Error).message : "Internal error" }, rc)
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(--primaryColor)"`), an opaque `0xRRGGBB` number or `[r, g, b(, a)]` in
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 | File | string | null,
59
- backgroundSize?: "cover" | "contain" | "tile",
60
- bgSize?: "cover" | "contain" | "tile",
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
@@ -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