lecodes-sdk 2.0.7 → 2.0.9

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 (68) hide show
  1. package/dist/global.d.ts +6 -0
  2. package/dist/types/g2/Scene2D.d.ts +4 -0
  3. package/dist/types/gl/Light.d.ts +36 -1
  4. package/dist/types/gl/Scene.d.ts +4 -0
  5. package/dist/types/inject.d.ts +2 -1
  6. package/dist/types/runtime/rpc.d.ts +2 -0
  7. package/dist/types/server/context.d.ts +4 -0
  8. package/dist/types/server/db/defineDb.d.ts +1 -1
  9. package/dist/types/server/db/fields.d.ts +25 -1
  10. package/dist/types/server/db/marci/query.d.ts +39 -2
  11. package/dist/types/server/db/types.d.ts +13 -7
  12. package/dist/types/server/files/db.d.ts +39 -0
  13. package/dist/types/server/files/models.d.ts +131 -0
  14. package/dist/types/server/inject.d.ts +1 -0
  15. package/dist/types/ui/NativeView.d.ts +3 -0
  16. package/dist/types/ui/UI.d.ts +1 -0
  17. package/dist/types/ui/UILayer.d.ts +25 -0
  18. package/dist/types/ui/UIModal.d.ts +20 -16
  19. package/dist/types/ui/UIPopover.d.ts +5 -3
  20. package/dist/types/ui/UIVideo.d.ts +3 -0
  21. package/dist/types/ui/UIWidget.d.ts +25 -24
  22. package/dist/types/ui/presentable.d.ts +10 -0
  23. package/dist/types/ui/transitions.d.ts +2 -1
  24. package/dist/types/ui/tree.d.ts +1 -0
  25. package/dist/types/version.d.ts +1 -1
  26. package/dist/types.json +1 -1
  27. package/package.json +3 -2
  28. package/prompts/3d-scene-files.md +3 -3
  29. package/prompts/3d-scene.md +1 -1
  30. package/prompts/dist/3d-app.md +4 -4
  31. package/src/bridges/gl.d.ts +5 -0
  32. package/src/bridges/tree.d.ts +24 -12
  33. package/src/chisel.ts +1 -1
  34. package/src/compile/libraryImports.ts +9 -0
  35. package/src/compile/serverTypes.ts +6 -0
  36. package/src/g2/Scene2D.ts +10 -0
  37. package/src/gl/Light.ts +78 -2
  38. package/src/gl/Material.ts +3 -0
  39. package/src/gl/Scene.ts +11 -1
  40. package/src/inject.ts +2 -0
  41. package/src/runtime/rpc.ts +15 -3
  42. package/src/server/context.ts +4 -0
  43. package/src/server/db/defineDb.ts +62 -9
  44. package/src/server/db/fields.ts +21 -1
  45. package/src/server/db/httpTransport.ts +9 -1
  46. package/src/server/db/marci/query.ts +71 -4
  47. package/src/server/db/types.ts +16 -5
  48. package/src/server/files/db.ts +182 -0
  49. package/src/server/files/host.ts +496 -0
  50. package/src/server/files/models.ts +116 -0
  51. package/src/server/host.ts +17 -4
  52. package/src/server/inject.ts +1 -0
  53. package/src/server/runtime.ts +41 -0
  54. package/src/server/validate.ts +5 -0
  55. package/src/ui/NativeView.ts +11 -0
  56. package/src/ui/UI.ts +1 -0
  57. package/src/ui/UIBottomSheet.ts +8 -7
  58. package/src/ui/UILayer.ts +88 -0
  59. package/src/ui/UIModal.ts +59 -43
  60. package/src/ui/UINode.ts +9 -1
  61. package/src/ui/UIPopover.ts +23 -9
  62. package/src/ui/UIVideo.ts +11 -0
  63. package/src/ui/UIWidget.ts +70 -44
  64. package/src/ui/presentable.ts +11 -1
  65. package/src/ui/transitions.ts +36 -7
  66. package/src/ui/tree.ts +4 -0
  67. package/src/version.ts +1 -1
  68. package/tests/helpers/fakeTree.ts +7 -3
package/package.json CHANGED
@@ -42,9 +42,10 @@
42
42
  "@types/node": "^25.9.1",
43
43
  "typescript": "~5.8.3",
44
44
  "gl-matrix": "^3.4.4",
45
- "marcidb-embedded": "^0.13.0"
45
+ "marcidb-embedded": "^0.14.1",
46
+ "sharp": "^0.35.2"
46
47
  },
47
- "version": "2.0.7",
48
+ "version": "2.0.9",
48
49
  "files": [
49
50
  "src",
50
51
  "dist",
@@ -66,10 +66,10 @@ export class Spinner extends Aspect<'spinner'> {
66
66
  // ===== UI OVER AN OPEN SCENE =====
67
67
 
68
68
  // Exactly one Presentable is visible at a time: UIScreen(...).open() REPLACES the scene. A HUD is
69
- // a UIWidget attached to the scene — it shows/hides and transitions together with it:
69
+ // a UIWidget that is the scene's CONTENT — it shows and hides together with it:
70
70
  const hud = UIWidget(UIText('Score: 0').style({ color: 'white', fontSize: 24 }))
71
71
  .style({ top: 'max(safe-top, 16px)', left: 16 })
72
- hud.attachTo(scene).show()
72
+ scene.setContent([hud])
73
73
 
74
74
  // ===== SCENE FILE MISTAKES — DO NOT DO THESE =====
75
75
 
@@ -107,7 +107,7 @@ const { scene, nodes } = await mainScene.open()
107
107
 
108
108
  let taps = 0
109
109
  const label = UIText('Tap the crate').style({ color: 'white', fontSize: 20, fontWeight: 700 })
110
- UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 }).attachTo(scene).show()
110
+ scene.setContent([UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 })])
111
111
 
112
112
  nodes.crate.addEventListener('click', () => { label.text = `Taps: ${++taps}` })
113
113
  </file>
@@ -114,6 +114,6 @@ const hud = UIWidget(
114
114
  scoreLabel = UIText('Score: 0').style({ color: 'white', fontSize: 24, fontWeight: 700 }),
115
115
  ).style({ top: 'max(safe-top, 16px)', right: 16 })
116
116
 
117
+ scene.setContent([hud])
117
118
  scene.open()
118
- hud.attachTo(scene).show()
119
119
  </file>
@@ -775,8 +775,8 @@ const hud = UIWidget(
775
775
  scoreLabel = UIText('Score: 0').style({ color: 'white', fontSize: 24, fontWeight: 700 }),
776
776
  ).style({ top: 'max(safe-top, 16px)', right: 16 })
777
777
 
778
+ scene.setContent([hud])
778
779
  scene.open()
779
- hud.attachTo(scene).show()
780
780
  </file>
781
781
 
782
782
  ## Scene files (.scene.ts)
@@ -847,10 +847,10 @@ export class Spinner extends Aspect<'spinner'> {
847
847
  // ===== UI OVER AN OPEN SCENE =====
848
848
 
849
849
  // Exactly one Presentable is visible at a time: UIScreen(...).open() REPLACES the scene. A HUD is
850
- // a UIWidget attached to the scene — it shows/hides and transitions together with it:
850
+ // a UIWidget that is the scene's CONTENT — it shows and hides together with it:
851
851
  const hud = UIWidget(UIText('Score: 0').style({ color: 'white', fontSize: 24 }))
852
852
  .style({ top: 'max(safe-top, 16px)', left: 16 })
853
- hud.attachTo(scene).show()
853
+ scene.setContent([hud])
854
854
 
855
855
  // ===== SCENE FILE MISTAKES — DO NOT DO THESE =====
856
856
 
@@ -888,7 +888,7 @@ const { scene, nodes } = await mainScene.open()
888
888
 
889
889
  let taps = 0
890
890
  const label = UIText('Tap the crate').style({ color: 'white', fontSize: 20, fontWeight: 700 })
891
- UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 }).attachTo(scene).show()
891
+ scene.setContent([UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 })])
892
892
 
893
893
  nodes.crate.addEventListener('click', () => { label.text = `Taps: ${++taps}` })
894
894
  </file>
@@ -95,6 +95,11 @@ declare global {
95
95
  /** Punctual light. `intensity` is luminous POWER in lumens; `falloff` is the metres of
96
96
  * influence (filament's own default is 1 m, i.e. invisible, so it is always passed). */
97
97
  createPointLight?(entityId: u32, intensity: f64, color: u32, falloff: f64, castShadows: boolean): void
98
+ /** Spot light: the point light's lumens and falloff in a cone along the entity's local -Z. `inner` / `outer` are
99
+ * HALF-angles in radians (outer ≤ π/2); the cone does not change the brightness. Real time only (the bake skips it). */
100
+ createSpotLight?(entityId: u32, intensity: f64, color: u32, falloff: f64, inner: f32, outer: f32, castShadows: boolean): void
101
+ /** A spot light's cone, live — half-angles in radians, as createSpotLight. */
102
+ setLightCone?(entityId: u32, inner: f32, outer: f32): void
98
103
  /** The lightmap bake takes this point light as a RECTANGLE of `width` x `height` metres (an area light: a ceiling
99
104
  * panel) in the light's local XZ plane, emitting along its local -Y with the same lumens; 0, 0 = a point again. */
100
105
  setLightBakeArea?(entityId: u32, width: f32, height: f32): void
@@ -112,10 +112,20 @@ declare global {
112
112
  isViewSupported(name: string): boolean
113
113
  viewVersion(name: string): i32
114
114
  viewCall(viewId: i32, method: string, args: value, onComplete: (result?: value) => void, onReject: (message: string) => void): void
115
- // Show a widget root; `ownerKind` / `ownerId` bind it to a destination (widget.attachTo: it
116
- // mounts inside that page and rides its transition) — TREE_DEST_NONE = a global overlay.
117
- showWidget(id: TreeNodeId, ownerKind: u8, ownerId: f64): void
118
- hideWidget(id: TreeNodeId): void
115
+ // ---- widgets (docs/plans/widgets-plan.md): a widget is a CHILD — of a screen root, of a
116
+ // destination's content layer, of the app layer — never a root of its own; `insert` /
117
+ // `remove` place it, `display` shows it, the runtime lays it out in the outermost presented
118
+ // destination's box (the portal) and mounts it under that root for the host. ------------
119
+ // The content LAYER of a destination: a "layer" root (a pass-through root laid out at the frame,
120
+ // shown while the destination is presented) whose widget children are the surface's UI — a HUD
121
+ // over a scene, a card over a map. `kind` TREE_DEST_NONE (id 0) = the APP layer, above every
122
+ // destination, always shown. 0 takes the layer away.
123
+ setLayer(kind: u8, id: f64, layer: TreeNodeId): void
124
+ // The pose a widget child enters from and leaves to: a transition id (defineTransition) whose
125
+ // ALONE roles the runtime plays as tracks on the widget when its resolved `display` flips —
126
+ // `none → flex` from the enter pose to rest, `flex → none` from rest to the exit pose, `none`
127
+ // taking effect when the tracks land. TRANSITION_NONE = no pose (the flip is instant).
128
+ setWidgetPose(id: TreeNodeId, transition: i32): void
119
129
 
120
130
  // ---- pager (a "pager" node's children are its pages; TAB-MAJOR order, `lens` = per-tab stack
121
131
  // lengths, `index` = the selected tab). A change of the current tab's stack plays a
@@ -251,11 +261,16 @@ export interface HostUI {
251
261
  * call's arguments, valid for the call. Settle exactly one of the two callbacks through the
252
262
  * dispatch bridge — `onComplete` with the result as ONE value, or with none. */
253
263
  viewCall?(viewId: i32, method: string, args: value, onComplete: (result?: value) => void, onReject: (message: string) => void): void
254
- /** Show a widget root: `ownerKind` = TREE_DEST_* (0xFF = a global overlay), `ownerId` = the sceneId /
255
- * viewId of a scene / native owner (0 for a screen), `ownerNode` = the owner screen's root (a
256
- * screen destination or a pager page) — 0 otherwise. */
257
- widgetOpen(node: nodeid, ownerKind: u8, ownerId: i32, ownerNode: nodeid): void
258
- widgetClose(node: nodeid): void
264
+ /** A LAYER root is shown: a pass-through root the runtime lays out at the frame, above every
265
+ * destination (the app layer) or over the presented surface (a scene's / a native view's / a
266
+ * video's content layer). Its own box is never hit — only its children are — so a touch outside
267
+ * them reaches what is under the layer; it is painted after the destination, layers in open
268
+ * order. Its children are widgets (the runtime inserts them with insertNode, as under a screen
269
+ * root): a widget child with an overlay layer (`overlayColor`, any color — "transparent" too)
270
+ * intercepts every touch outside its own box within its root and reports it as the overlay tap. */
271
+ layerOpen(node: nodeid): void
272
+ /** The layer root is hidden (its view stays; releaseNode says when it is gone). */
273
+ layerClose(node: nodeid): void
259
274
  /** Load a font face for `fontFamily` (weight 100–900, style 0 normal / 2 italic) from `url`; settle
260
275
  * one of the callbacks through the dispatch bridge. */
261
276
  registerFont(url: string, fontFamily: string, weight: i32, style: i32, onComplete: () => void, onReject: (err: string) => void): void
@@ -289,9 +304,6 @@ export interface HostUI {
289
304
  * a child (covered by a push, a keepAlive page); a page that is released goes through
290
305
  * removeNode right after. */
291
306
  pagerDropPage?(node: nodeid, page: nodeid): void
292
- /** A pager page root (`page`) was mounted under the pager or released (an attachTo widget names the
293
- * page node as its owner). */
294
- pagerPageMounted?(page: nodeid, mounted: boolean): void
295
307
  /** Optional: the host may PLAY a UI tween track itself — a platform animation interpolating on its
296
308
  * own clock (Core Animation on iOS) instead of one style write per runtime frame. `samples` holds
297
309
  * the track evaluated at even points of its length, easing included, `lanes` floats each: 1 for a
package/src/chisel.ts CHANGED
@@ -129,7 +129,7 @@ export const chiselVocab: ChiselVocab = {
129
129
  "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent",
130
130
  "onEndReached", "onStartReached",
131
131
  "append", "insert", "remove", "setContent", "keepAlive", "url",
132
- "attachTo", "detents", "dismissible", "transition", "setSourceRect",
132
+ "detents", "dismissible", "transition", "setSourceRect",
133
133
  "push", "pop", "popToRoot", "replace", "select",
134
134
  // UITabs / UIVirtualizedList / the native views (NativeView, CameraView, MapView, QRScanner)
135
135
  "badge", "setData", "update", "prepend", "removeByKey",
@@ -31,6 +31,15 @@ export const rewriteLibraryImports = (src: string, slugs: Set<string>): string =
31
31
  if (isRelativeOrAbsolute(spec)) return spec
32
32
  const slash = spec.indexOf("/")
33
33
  const slug = slash === -1 ? spec : spec.slice(0, slash)
34
+ // The SDK's plugin bases (`lecodes-sdk/plugin`: ServiceChannel, ViewChannel — what a plugin's
35
+ // generated wrapper extends): the one SDK module a PROJECT file may import, because a plugin of
36
+ // the project itself (`plugins/<id>/sdk/<id>.gen.ts`, written by `lecodes plugin gen`) lives in
37
+ // the project tree and not in the SDK. It is the SDK's own module under /__sdk, the same instance
38
+ // the vendored first-party wrappers reach by relative path. Anything else of the SDK is a global.
39
+ if (slug === "lecodes-sdk") {
40
+ if (spec === "lecodes-sdk/plugin" || spec.startsWith("lecodes-sdk/plugin/")) return `/__sdk${spec.slice("lecodes-sdk".length)}`
41
+ throw new Error(`import from "${spec}": the SDK needs no imports — its API is globals; only "lecodes-sdk/plugin" (a plugin wrapper's bases) is imported.`)
42
+ }
34
43
  if (slugs.has(slug)) {
35
44
  // `acme-ui` → `/__lib/acme-ui/index`; `acme-ui/theme` → `/__lib/acme-ui/theme`.
36
45
  const sub = slash === -1 ? "/index" : spec.slice(slash)
@@ -127,6 +127,8 @@ const wrongSideGlobals = (ts: TS, program: import("typescript").Program, checker
127
127
  return out
128
128
  }
129
129
 
130
+ const FILE_TYPE_PATH = "/__lecodes/file.d.ts"
131
+
130
132
  export type DeriveResult = {
131
133
  /** endpoint id → parameter schemas (only endpoints the checker could see). */
132
134
  params: Record<string, ParamSchema[]>
@@ -152,6 +154,9 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
152
154
  if (!/\.[tj]sx?$/.test(e.path)) continue
153
155
  files.set(normalizePath(e.path), e.text)
154
156
  }
157
+ // The one SDK type this program knows: a parameter typed `File` is an upload, and has to be told
158
+ // from "some object". A declaration of the type alone — as a value the name stays unknown here.
159
+ files.set(FILE_TYPE_PATH, "interface File { readonly __lecodesFile: true, readonly name: string, readonly size: number, readonly type: string }\n")
155
160
  // the server modules, and every other file of the project: the app's are read for the globals they use
156
161
  const roots = [...new Set([...Object.keys(modules).map(normalizePath).filter(p => files.has(p)), ...files.keys()])]
157
162
 
@@ -346,6 +351,7 @@ const lower = (ts: TS, checker: import("typescript").TypeChecker, type: import("
346
351
  }
347
352
  if (f & T.Union) return lowerMembers(ts, checker, (type as import("typescript").UnionType).types, where, stack)
348
353
  if (f & T.Intersection || f & T.Object) {
354
+ if (type.getProperty("__lecodesFile")) return { t: "file" }
349
355
  if (stack.has(type)) throw new Error(`${where}: recursive type ${checker.typeToString(type)}`)
350
356
  if (type.getCallSignatures().length || type.getConstructSignatures().length) throw new Error(`${where}: functions are not JSON`)
351
357
  stack.add(type)
package/src/g2/Scene2D.ts CHANGED
@@ -6,6 +6,7 @@ import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem,
6
6
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "../ui/presentable"
7
7
  import { _transitionId, TRANSITION_NONE } from "../ui/transitions"
8
8
  import { TREE_DEST_SCENE2D, ensureEmitter, registerDest } from "../ui/tree"
9
+ import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "../ui/UILayer"
9
10
  import { cx, cy, type Vec2Like } from "../math/vec"
10
11
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
11
12
  import { Camera2D } from "./Camera2D"
@@ -188,6 +189,15 @@ export class Scene2D implements Presentable {
188
189
  registerDest(TREE_DEST_SCENE2D, this.id, this)
189
190
  return [TREE_DEST_SCENE2D, this.id, "", ""]
190
191
  }
192
+ /** @internal The scene's content layer (ui/UILayer.ts), made by the first setContent. */
193
+ _contentLayer?: LayerElement
194
+ /** The UI over the scene — widgets (a HUD, a dialog), laid out over the scene while it is
195
+ * presented; touches outside a widget's box reach the scene. */
196
+ setContent(content: UIWidgetContent): this {
197
+ ensureEmitter()
198
+ _setSurfaceContent(this, content)
199
+ return this
200
+ }
191
201
  /** @internal presentation lifecycle from the runtime */
192
202
  _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
193
203
  /** @internal */
package/src/gl/Light.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Lights. A directional sun (the engine's primary light) and punctual point lights. Add one to a
1
+ // Lights. A directional sun (the engine's primary light), punctual point lights and spot lights. Add one to a
2
2
  // scene like any node; `intensity` and `color` stay writable so a light can be animated.
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
@@ -80,6 +80,24 @@ export type PointOptions = {
80
80
  bakeArea?: readonly [number, number]
81
81
  }
82
82
 
83
+ export type SpotOptions = {
84
+ /**
85
+ * Luminous power in lumens, as a point light's. The cone does not change the brightness: widen it and the
86
+ * same lumens light a larger patch at the same level. On its axis a spot is 4x brighter than a point light of
87
+ * the same lumens (its light is not spread over the whole sphere). Default 1000.
88
+ */
89
+ intensity?: number
90
+ color?: ColorInput
91
+ /** Metres of influence along the cone, the performance knob as for a point light. Default 10. */
92
+ range?: number
93
+ /** The cone's full angle in degrees, up to 180: past it the light contributes nothing. Default 45. */
94
+ angle?: number
95
+ /** The full angle in degrees of the cone's fully lit core; the light fades from it out to `angle`. Default 0.75 x `angle`. */
96
+ innerAngle?: number
97
+ /** A spot's shadow is one 2D shadow map (a point light's is a cubemap); off by default. */
98
+ castShadows?: boolean
99
+ }
100
+
83
101
  export class Light extends Node {
84
102
  private _intensity = 0
85
103
 
@@ -159,7 +177,65 @@ export class Light extends Node {
159
177
  /** Whether this host can create point lights at all. */
160
178
  static get supportsPoint(): boolean { return _creator.createPointLight !== undefined }
161
179
 
162
- /** Live intensity (sun: lux, point: lumens) — animate a flash without rebuilding the light. */
180
+ /** @internal a spot's cone, full angles in degrees (the engine takes half-angles in radians). */
181
+ _angle = 0
182
+ _innerAngle = 0
183
+
184
+ /**
185
+ * A spot light — a flashlight, a headlight, a stage light. It shines along the node's forward (-Z): position
186
+ * it like any node and aim it with `lookAt`. Real time only: the lightmap bake takes no spot lights, so a spot
187
+ * lights the baked statics live too.
188
+ *
189
+ * Feature-detected like `Light.point`: on a host without it the node exists and lights nothing
190
+ * (`Light.supportsSpot`).
191
+ */
192
+ static spot(options: SpotOptions = {}): Light {
193
+ const light = new Light()
194
+ light._intensity = options.intensity ?? 1000
195
+ light._color = options.color ?? 0xffffff
196
+ light._baked = false
197
+ light._angle = options.angle ?? 45
198
+ light._innerAngle = options.innerAngle ?? light._angle * 0.75
199
+ const [ inner, outer ] = light._cone()
200
+ _creator.createSpotLight?.(
201
+ light.id,
202
+ light._intensity,
203
+ Color.toPackedRgb(light._color),
204
+ options.range ?? 10,
205
+ inner, outer,
206
+ options.castShadows ?? false,
207
+ )
208
+ // the statics of a lightmapped level are on Filament channel 1 alone (see Lightmap)
209
+ _creator.setLightChannel?.(light.id, 1, true)
210
+ return light
211
+ }
212
+
213
+ /** Whether this host can create spot lights. */
214
+ static get supportsSpot(): boolean { return _creator.createSpotLight !== undefined }
215
+
216
+ /** @internal the cone as the engine takes it: half-angles in radians, outer in (0, 90°], inner in [0, outer]. */
217
+ _cone(): [number, number] {
218
+ const rad = Math.PI / 360
219
+ const outer = Math.min(Math.max(this._angle, 0.1), 180) * rad
220
+ const inner = Math.min(Math.max(this._innerAngle, 0), this._angle) * rad
221
+ return [ Math.min(inner, outer), outer ]
222
+ }
223
+
224
+ /** A spot's full cone angle in degrees — live (a flashlight's focus). */
225
+ get angle(): number { return this._angle }
226
+ set angle(value: number) {
227
+ this._innerAngle *= this._angle > 0 ? value / this._angle : 0
228
+ this._angle = value
229
+ _creator.setLightCone?.(this.id, ...this._cone())
230
+ }
231
+ /** A spot's fully lit core, full angle in degrees — live. Setting `angle` scales it along. */
232
+ get innerAngle(): number { return this._innerAngle }
233
+ set innerAngle(value: number) {
234
+ this._innerAngle = value
235
+ _creator.setLightCone?.(this.id, ...this._cone())
236
+ }
237
+
238
+ /** Live intensity (sun: lux, point / spot: lumens) — animate a flash without rebuilding the light. */
163
239
  get intensity(): number { return this._intensity }
164
240
  set intensity(value: number) {
165
241
  this._intensity = value
@@ -187,6 +187,9 @@ export class Material {
187
187
  } else if ("_h" in (shader as any)) {
188
188
  this.shader = shader as FetchResponse
189
189
  this._h = _creator.createMaterial((shader as any)._h.id) // owned: dropped and unused → freed
190
+ // 0xFFFFFFFF = the engine refused the package: no shader in it for the running backend (a
191
+ // phone on Vulkan handed the mobile `_opengl` artifact — the host's log names what is missing).
192
+ if (this._h.id === 0xFFFFFFFF) throw new Error("Material: the engine refused the shader (no variant for its backend — see the host log)")
190
193
  } else {
191
194
  this.shader = "unknown"
192
195
  this._h = shader as Handle // an instance the runtime already wrapped for us
package/src/gl/Scene.ts CHANGED
@@ -10,6 +10,7 @@ import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem,
10
10
  import { _bumpNavEpoch, _navEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "../ui/presentable"
11
11
  import { _transitionId, TRANSITION_NONE } from "../ui/transitions"
12
12
  import { TREE_DEST_SCENE3D, TREE_FLAG_BACK, ensureEmitter, registerDest, unregisterDest } from "../ui/tree"
13
+ import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "../ui/UILayer"
13
14
  import { device } from "../runtime/device"
14
15
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
15
16
  import type { FetchResponse } from "../runtime/fetch"
@@ -478,10 +479,19 @@ export class Scene implements Presentable {
478
479
 
479
480
  /** @internal The destination tuple (ui/presentable.ts). */
480
481
  _dest(): DestTuple {
481
- // Addressed by the router / an attached widget: make sure the (kind, id) resolves back to us.
482
+ // Addressed by the router / its content layer: make sure the (kind, id) resolves back to us.
482
483
  registerDest(TREE_DEST_SCENE3D, this._id, this)
483
484
  return [TREE_DEST_SCENE3D, this._id, "", ""]
484
485
  }
486
+ /** @internal The scene's content layer (ui/UILayer.ts), made by the first setContent. */
487
+ _contentLayer?: LayerElement
488
+ /** The UI over the scene — widgets (a HUD, a dialog), laid out over the scene while it is
489
+ * presented; touches outside a widget's box reach the scene. `scene.setContent([hud])`. */
490
+ setContent(content: UIWidgetContent): this {
491
+ ensureEmitter()
492
+ _setSurfaceContent(this, content)
493
+ return this
494
+ }
485
495
  /** @internal presentation lifecycle from the runtime */
486
496
  _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
487
497
  /** @internal */
package/src/inject.ts CHANGED
@@ -44,6 +44,7 @@ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, t
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
46
  export type { ChannelSubscription } from "./server/channel"
47
+ export type { StoredFile, StoredImage } from "./server/files/models"
47
48
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
48
49
  // Game audio (docs/audio-plan.md): decoded clips, a voice pool, buses with effects; 3D through the
49
50
  // AudioSource / AudioZone aspects (gl/) and scene.audio.
@@ -101,6 +102,7 @@ export {
101
102
  UITabs, type UITabDef,
102
103
  defineTabs, type TabDef, type TabsHandle,
103
104
  UIWidget, type UIWidgetStyle,
105
+ UIOverlay, type UIWidgetContent,
104
106
  UIModal, type UIModalStyle,
105
107
  UIBottomSheet, type UIBottomSheetStyle,
106
108
  UIPopover, type UIPopoverStyle,
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * Wire (implemented by the runner):
11
11
  * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
12
+ * — with a `File` among the arguments: multipart, the field `args` is
13
+ * that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
12
14
  * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
13
15
  * WS <serverUrl>/ws the channel socket — its frames are ./wire.ts
14
16
  *
@@ -17,7 +19,7 @@
17
19
  * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
18
20
  */
19
21
 
20
- import { fetch } from "./fetch"
22
+ import { fetch, File, FormData } from "./fetch"
21
23
  import { localStorage } from "./storage"
22
24
  import { WebSocket } from "./net"
23
25
  import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
@@ -64,12 +66,22 @@ export const __rpc = (serverUrl: string, id: string) => {
64
66
  // an optional argument left out is `undefined` in its place — JSON has no such value (it would
65
67
  // travel as null and fail the parameter's type), and it is the last ones
66
68
  while (args.length && args[args.length - 1] === undefined) args.pop()
67
- const headers: Record<string, string> = { "content-type": "application/json" }
69
+ const headers: Record<string, string> = {}
68
70
  const session = getSession(serverUrl)
69
71
  if (session) headers.authorization = `Bearer ${session}`
72
+ // a File anywhere among the arguments leaves its place to a marker and travels beside them
73
+ const files: File[] = []
74
+ const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
75
+ let payload: string | FormData = json
76
+ if (files.length) {
77
+ const form = new FormData()
78
+ form.append("args", json)
79
+ files.forEach((file, i) => form.append(`file${i}`, file, file.name))
80
+ payload = form
81
+ } else headers["content-type"] = "application/json"
70
82
  let res
71
83
  try {
72
- res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
84
+ res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: payload })
73
85
  } catch (e) {
74
86
  throw new RpcError(0, `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})`)
75
87
  }
@@ -15,6 +15,10 @@ export type RequestContext = {
15
15
  sessionToken?: string
16
16
  /** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
17
17
  auth?: unknown
18
+ /** The files a multipart call carried, as the receiving process left them (./files/host.ts `Upload`). */
19
+ uploads?: unknown[]
20
+ /** What `/files/<id>/<name>` is appended to in a stored file's url: the backend's own base, as this request reached it. */
21
+ filesBase?: string
18
22
  }
19
23
 
20
24
  type Provider = () => RequestContext | undefined
@@ -8,9 +8,11 @@
8
8
  * never sees a connection string.
9
9
  */
10
10
 
11
- import type { Field, FieldDef, ScalarKind } from "./fields"
11
+ import { t, type Field, type FieldDef, type ScalarKind } from "./fields"
12
12
  import { createAuthApi } from "../auth/api"
13
13
  import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityFields, sessionFields } from "../auth/models"
14
+ import { createFileLayer } from "../files/db"
15
+ import { FILE_MODEL, fileFields, fileOwnerKey, type FileField } from "../files/models"
14
16
  import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
15
17
  import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
16
18
 
@@ -63,7 +65,12 @@ const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> =>
63
65
  for (const [name, m] of Object.entries(models)) {
64
66
  if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`defineDb: model name "${name}" must be PascalCase`)
65
67
  const fields: Record<string, FieldDef> = {}
66
- for (const [key, f] of Object.entries(m.fields as Fields)) fields[key] = { ...(f as Field<any, any>).def }
68
+ for (const [key, f] of Object.entries(m.fields as Fields)) {
69
+ const def = (f as Field<any, any>).def
70
+ if (def.kind === "struct" && Object.values(def.fields!).some(inner => (inner as Field<any, any>).def.kind === "file"))
71
+ throw new Error(`defineDb: ${name}.${key} — a file belongs to a model's row: put the t.file() field on ${name} itself, not inside a struct`)
72
+ fields[key] = { ...def }
73
+ }
67
74
  if (fields.id && !fields.id.isId) fields.id.isId = true
68
75
  const idKeys = Object.keys(fields).filter(k => fields[k].isId)
69
76
  const uuidId = idKeys.length === 1 && idKeys[0] === "id" && fields.id.scalar === "uuid"
@@ -133,6 +140,7 @@ const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): st
133
140
  case "one": type = d.model!; break
134
141
  case "many": type = `${d.model}[]`; attrs.push(`@bind(${d.model}.${d.back})`); break
135
142
  case "list": type = `${d.model}[]`; attrs.push("@list"); break
143
+ case "file": throw new Error(`${owner}.${key}: a file field is emitted as the relation it stands for`)
136
144
  }
137
145
  if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
138
146
  if (d.optional) type += "?"
@@ -164,12 +172,14 @@ export const toMarci = (metas: Record<string, ModelMeta>): string => {
164
172
  * model — `key` / `body` scalars, `one` / `many` relations with their target. Structs are pseudo-models
165
173
  * (`User.info`) so an empty nested select fills with the struct's own scalars, as marcidb does.
166
174
  */
167
- const modelsMeta = (metas: Record<string, ModelMeta>): ModelsMeta => {
175
+ const modelsMeta = (metas: Record<string, ModelMeta>, isFile: (model: string, key: string) => boolean): ModelsMeta => {
168
176
  const out: Record<string, FieldDesc[]> = {}
169
177
  const describe = (name: string, fields: Record<string, FieldDef>, implicitId: boolean) => {
170
178
  const descs: FieldDesc[] = implicitId ? [{ n: "id", k: "key" }] : []
171
179
  for (const [key, d] of Object.entries(fields)) {
172
- if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
180
+ // a file reads like a scalar: a query without a selection returns it (../files/db.ts makes it the relation select)
181
+ if (isFile(name, key)) descs.push({ n: key, k: "body" })
182
+ else if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
173
183
  else if (d.kind === "struct") {
174
184
  const sub = `${name}.${key}`
175
185
  const inner: Record<string, FieldDef> = {}
@@ -204,21 +214,51 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
204
214
  for (const name of [AUTH_SESSION_MODEL, AUTH_IDENTITY_MODEL]) {
205
215
  if (name in own) throw new Error(`defineDb: "${name}" is the platform's own model (it comes with .withAuth) — name yours differently`)
206
216
  }
217
+ if (FILE_MODEL in own) throw new Error(`defineDb: "${FILE_MODEL}" is the platform's own model (it comes with a t.file() field) — name yours differently`)
218
+ // the schema's file fields: each is, in the database, the reverse side of a reference on the platform's File
219
+ const fileList: FileField[] = []
220
+ for (const [name, m] of Object.entries(own)) {
221
+ for (const [key, f] of Object.entries(m.fields as Fields)) {
222
+ const def = (f as Field<any, any>).def
223
+ if (def.kind === "file") fileList.push({ model: name, key, array: def.array, owner: fileOwnerKey(name, key), ...(def.image ? { image: def.image } : {}) })
224
+ }
225
+ }
207
226
  // What is wrong with the project's own models is said HERE, at the definition, not at the first use:
208
227
  // checked against a stand-in for the one platform model they may point at.
209
228
  buildMeta({ ...own, [AUTH_SESSION_MODEL]: model({}) })
210
229
 
211
230
  let authModel: string | null = null
212
- let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null } | null = null
231
+ let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null, files: FileField[] } | null = null
213
232
 
214
233
  const transport = () => {
215
234
  const t = options.transport ?? seam.__lecodesDbTransport
216
235
  if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
217
236
  return t
218
237
  }
238
+ // A db with file fields runs every operation through the file layer: a write of a file field is the
239
+ // row's operation and the ones on File after it, in one transaction.
240
+ let files: ReturnType<typeof createFileLayer> | null = null
241
+ const exec = async (op: MarciOp): Promise<any> => {
242
+ if (!files) return transport().exec(op)
243
+ const plan = await files.plan(op, 0)
244
+ if (!plan.after.length) return files.result(plan.op, await transport().exec(plan.op))
245
+ return (await transport().batch([plan.op, ...plan.after]))[0]
246
+ }
247
+ const batch = async (ops: MarciOp[]): Promise<any[]> => {
248
+ if (!files) return transport().batch(ops)
249
+ // the project's operations keep their places (its `ref`s count them); what follows them goes after
250
+ const plans: Awaited<ReturnType<typeof files.plan>>[] = []
251
+ for (let index = 0; index < ops.length; index++) plans.push(await files.plan(ops[index], index))
252
+ const results = await transport().batch([...plans.map(p => p.op), ...plans.flatMap(p => p.after)])
253
+ return plans.map((p, index) => files!.result(p.op, results[index]))
254
+ }
219
255
  const seal = () => {
220
256
  if (sealed) return sealed
221
257
  const models: Record<string, Model> = { ...own }
258
+ if (fileList.length) {
259
+ for (const f of fileList) models[f.model] = model({ ...(models[f.model].fields as Fields), [f.key]: t.many(FILE_MODEL, f.owner) })
260
+ models[FILE_MODEL] = model(fileFields(fileList))
261
+ }
222
262
  if (authModel) {
223
263
  models[AUTH_SESSION_MODEL] = model(sessionFields(authModel))
224
264
  models[AUTH_IDENTITY_MODEL] = model(identityFields(authModel))
@@ -246,10 +286,21 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
246
286
  for (const [key, value] of missing) out[key] = [...value]
247
287
  return out
248
288
  }
289
+ const isFile = (name: string, key: string) => fileList.some(f => f.model === name && f.key === key)
290
+ const meta = modelsMeta(metas, isFile)
291
+ if (fileList.length) {
292
+ const relations: Record<string, Record<string, string>> = {}
293
+ const scalars: Record<string, string[]> = {}
294
+ for (const [name, descs] of Object.entries(meta)) {
295
+ relations[name] = Object.fromEntries(descs.filter(d => d.m).map(d => [d.n, d.m!]))
296
+ scalars[name] = descs.filter(d => d.k === "key" || d.k === "body").map(d => d.n)
297
+ }
298
+ files = createFileLayer(fileList, relations, scalars)
299
+ }
249
300
  // marcidb's own query builder over our metadata; the transport is the only host-specific piece
250
301
  const { collection } = createQueryLayer({
251
- models: modelsMeta(metas),
252
- run: op => transport().exec(op),
302
+ models: meta,
303
+ run: exec,
253
304
  // a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
254
305
  prepareInsert: (name, raw) => {
255
306
  const data = withDefaults(name, raw)
@@ -262,12 +313,13 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
262
313
  models, schema: toMarci(metas), collections,
263
314
  // the platform's models are reached by role, never as `db.session` / `db.identity`
264
315
  auth: authModel ? { model: authModel, user: collection(authModel), session: collection(AUTH_SESSION_MODEL), identity: collection(AUTH_IDENTITY_MODEL) } : null,
316
+ files: fileList,
265
317
  }
266
318
  return sealed
267
319
  }
268
320
 
269
321
  const db: Record<string, unknown> = {
270
- $transaction: async (ops: unknown[]) => { seal(); return transport().batch(ops.map(opOf)) },
322
+ $transaction: async (ops: unknown[]) => { seal(); return batch(ops.map(opOf)) },
271
323
  withAuth: ({ model: name }: { model: string }) => {
272
324
  if (sealed) throw new Error("defineDb(...).withAuth(): this db has already been used — sign-in is part of the definition, write it on defineDb(...) itself")
273
325
  if (authModel) throw new Error("defineDb(...).withAuth(): called twice on one db")
@@ -276,7 +328,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
276
328
  for (const [key, f] of Object.entries(user.fields as Fields)) {
277
329
  const d = (f as Field<any, any>).def
278
330
  if (d.isId || key === "id") throw new Error(`defineDb(...).withAuth(): ${name} must keep the built-in id (it declares "${key}" as its key) — sessions and sign-in methods point at it`)
279
- if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault) continue
331
+ if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault || (d.kind === "file" && d.array)) continue
280
332
  throw new Error(`defineDb(...).withAuth(): ${name}.${key} needs .optional() or .default(...) — the platform creates the row the first time someone signs in`)
281
333
  }
282
334
  authModel = name
@@ -288,6 +340,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
288
340
  $schema: { get: () => seal().schema },
289
341
  $models: { get: () => seal().models },
290
342
  $auth: { get: () => seal().auth },
343
+ $files: { get: () => seal().files },
291
344
  })
292
345
  for (const name of Object.keys(own)) {
293
346
  Object.defineProperty(db, uncap(name), { enumerable: true, get: () => seal().collections[name] })