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.
- package/dist/global.d.ts +6 -0
- package/dist/types/g2/Scene2D.d.ts +4 -0
- package/dist/types/gl/Light.d.ts +36 -1
- package/dist/types/gl/Scene.d.ts +4 -0
- package/dist/types/inject.d.ts +2 -1
- package/dist/types/runtime/rpc.d.ts +2 -0
- package/dist/types/server/context.d.ts +4 -0
- package/dist/types/server/db/defineDb.d.ts +1 -1
- package/dist/types/server/db/fields.d.ts +25 -1
- package/dist/types/server/db/marci/query.d.ts +39 -2
- package/dist/types/server/db/types.d.ts +13 -7
- package/dist/types/server/files/db.d.ts +39 -0
- package/dist/types/server/files/models.d.ts +131 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/ui/NativeView.d.ts +3 -0
- package/dist/types/ui/UI.d.ts +1 -0
- package/dist/types/ui/UILayer.d.ts +25 -0
- package/dist/types/ui/UIModal.d.ts +20 -16
- package/dist/types/ui/UIPopover.d.ts +5 -3
- package/dist/types/ui/UIVideo.d.ts +3 -0
- package/dist/types/ui/UIWidget.d.ts +25 -24
- package/dist/types/ui/presentable.d.ts +10 -0
- package/dist/types/ui/transitions.d.ts +2 -1
- package/dist/types/ui/tree.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +3 -2
- package/prompts/3d-scene-files.md +3 -3
- package/prompts/3d-scene.md +1 -1
- package/prompts/dist/3d-app.md +4 -4
- package/src/bridges/gl.d.ts +5 -0
- package/src/bridges/tree.d.ts +24 -12
- package/src/chisel.ts +1 -1
- package/src/compile/libraryImports.ts +9 -0
- package/src/compile/serverTypes.ts +6 -0
- package/src/g2/Scene2D.ts +10 -0
- package/src/gl/Light.ts +78 -2
- package/src/gl/Material.ts +3 -0
- package/src/gl/Scene.ts +11 -1
- package/src/inject.ts +2 -0
- package/src/runtime/rpc.ts +15 -3
- package/src/server/context.ts +4 -0
- package/src/server/db/defineDb.ts +62 -9
- package/src/server/db/fields.ts +21 -1
- package/src/server/db/httpTransport.ts +9 -1
- package/src/server/db/marci/query.ts +71 -4
- package/src/server/db/types.ts +16 -5
- package/src/server/files/db.ts +182 -0
- package/src/server/files/host.ts +496 -0
- package/src/server/files/models.ts +116 -0
- package/src/server/host.ts +17 -4
- package/src/server/inject.ts +1 -0
- package/src/server/runtime.ts +41 -0
- package/src/server/validate.ts +5 -0
- package/src/ui/NativeView.ts +11 -0
- package/src/ui/UI.ts +1 -0
- package/src/ui/UIBottomSheet.ts +8 -7
- package/src/ui/UILayer.ts +88 -0
- package/src/ui/UIModal.ts +59 -43
- package/src/ui/UINode.ts +9 -1
- package/src/ui/UIPopover.ts +23 -9
- package/src/ui/UIVideo.ts +11 -0
- package/src/ui/UIWidget.ts +70 -44
- package/src/ui/presentable.ts +11 -1
- package/src/ui/transitions.ts +36 -7
- package/src/ui/tree.ts +4 -0
- package/src/version.ts +1 -1
- 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.
|
|
45
|
+
"marcidb-embedded": "^0.14.1",
|
|
46
|
+
"sharp": "^0.35.2"
|
|
46
47
|
},
|
|
47
|
-
"version": "2.0.
|
|
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
|
|
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
|
-
|
|
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 })
|
|
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>
|
package/prompts/3d-scene.md
CHANGED
|
@@ -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>
|
package/prompts/dist/3d-app.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 })
|
|
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>
|
package/src/bridges/gl.d.ts
CHANGED
|
@@ -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
|
package/src/bridges/tree.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
"
|
|
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)
|
|
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
|
-
/**
|
|
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
|
package/src/gl/Material.ts
CHANGED
|
@@ -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 /
|
|
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,
|
package/src/runtime/rpc.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Wire (implemented by the runner):
|
|
11
11
|
* POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
|
|
12
|
+
* — with a `File` among the arguments: multipart, the field `args` is
|
|
13
|
+
* that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
|
|
12
14
|
* → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
|
|
13
15
|
* WS <serverUrl>/ws the channel socket — its frames are ./wire.ts
|
|
14
16
|
*
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
* User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
|
-
import { fetch } from "./fetch"
|
|
22
|
+
import { fetch, File, FormData } from "./fetch"
|
|
21
23
|
import { localStorage } from "./storage"
|
|
22
24
|
import { WebSocket } from "./net"
|
|
23
25
|
import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
|
|
@@ -64,12 +66,22 @@ export const __rpc = (serverUrl: string, id: string) => {
|
|
|
64
66
|
// an optional argument left out is `undefined` in its place — JSON has no such value (it would
|
|
65
67
|
// travel as null and fail the parameter's type), and it is the last ones
|
|
66
68
|
while (args.length && args[args.length - 1] === undefined) args.pop()
|
|
67
|
-
const headers: Record<string, string> = {
|
|
69
|
+
const headers: Record<string, string> = {}
|
|
68
70
|
const session = getSession(serverUrl)
|
|
69
71
|
if (session) headers.authorization = `Bearer ${session}`
|
|
72
|
+
// a File anywhere among the arguments leaves its place to a marker and travels beside them
|
|
73
|
+
const files: File[] = []
|
|
74
|
+
const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
|
|
75
|
+
let payload: string | FormData = json
|
|
76
|
+
if (files.length) {
|
|
77
|
+
const form = new FormData()
|
|
78
|
+
form.append("args", json)
|
|
79
|
+
files.forEach((file, i) => form.append(`file${i}`, file, file.name))
|
|
80
|
+
payload = form
|
|
81
|
+
} else headers["content-type"] = "application/json"
|
|
70
82
|
let res
|
|
71
83
|
try {
|
|
72
|
-
res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body:
|
|
84
|
+
res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: payload })
|
|
73
85
|
} catch (e) {
|
|
74
86
|
throw new RpcError(0, `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})`)
|
|
75
87
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -15,6 +15,10 @@ export type RequestContext = {
|
|
|
15
15
|
sessionToken?: string
|
|
16
16
|
/** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
|
|
17
17
|
auth?: unknown
|
|
18
|
+
/** The files a multipart call carried, as the receiving process left them (./files/host.ts `Upload`). */
|
|
19
|
+
uploads?: unknown[]
|
|
20
|
+
/** What `/files/<id>/<name>` is appended to in a stored file's url: the backend's own base, as this request reached it. */
|
|
21
|
+
filesBase?: string
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
type Provider = () => RequestContext | undefined
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* never sees a connection string.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import { t, type Field, type FieldDef, type ScalarKind } from "./fields"
|
|
12
12
|
import { createAuthApi } from "../auth/api"
|
|
13
13
|
import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityFields, sessionFields } from "../auth/models"
|
|
14
|
+
import { createFileLayer } from "../files/db"
|
|
15
|
+
import { FILE_MODEL, fileFields, fileOwnerKey, type FileField } from "../files/models"
|
|
14
16
|
import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
|
|
15
17
|
import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"
|
|
16
18
|
|
|
@@ -63,7 +65,12 @@ const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> =>
|
|
|
63
65
|
for (const [name, m] of Object.entries(models)) {
|
|
64
66
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`defineDb: model name "${name}" must be PascalCase`)
|
|
65
67
|
const fields: Record<string, FieldDef> = {}
|
|
66
|
-
for (const [key, f] of Object.entries(m.fields as Fields))
|
|
68
|
+
for (const [key, f] of Object.entries(m.fields as Fields)) {
|
|
69
|
+
const def = (f as Field<any, any>).def
|
|
70
|
+
if (def.kind === "struct" && Object.values(def.fields!).some(inner => (inner as Field<any, any>).def.kind === "file"))
|
|
71
|
+
throw new Error(`defineDb: ${name}.${key} — a file belongs to a model's row: put the t.file() field on ${name} itself, not inside a struct`)
|
|
72
|
+
fields[key] = { ...def }
|
|
73
|
+
}
|
|
67
74
|
if (fields.id && !fields.id.isId) fields.id.isId = true
|
|
68
75
|
const idKeys = Object.keys(fields).filter(k => fields[k].isId)
|
|
69
76
|
const uuidId = idKeys.length === 1 && idKeys[0] === "id" && fields.id.scalar === "uuid"
|
|
@@ -133,6 +140,7 @@ const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): st
|
|
|
133
140
|
case "one": type = d.model!; break
|
|
134
141
|
case "many": type = `${d.model}[]`; attrs.push(`@bind(${d.model}.${d.back})`); break
|
|
135
142
|
case "list": type = `${d.model}[]`; attrs.push("@list"); break
|
|
143
|
+
case "file": throw new Error(`${owner}.${key}: a file field is emitted as the relation it stands for`)
|
|
136
144
|
}
|
|
137
145
|
if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
|
|
138
146
|
if (d.optional) type += "?"
|
|
@@ -164,12 +172,14 @@ export const toMarci = (metas: Record<string, ModelMeta>): string => {
|
|
|
164
172
|
* model — `key` / `body` scalars, `one` / `many` relations with their target. Structs are pseudo-models
|
|
165
173
|
* (`User.info`) so an empty nested select fills with the struct's own scalars, as marcidb does.
|
|
166
174
|
*/
|
|
167
|
-
const modelsMeta = (metas: Record<string, ModelMeta
|
|
175
|
+
const modelsMeta = (metas: Record<string, ModelMeta>, isFile: (model: string, key: string) => boolean): ModelsMeta => {
|
|
168
176
|
const out: Record<string, FieldDesc[]> = {}
|
|
169
177
|
const describe = (name: string, fields: Record<string, FieldDef>, implicitId: boolean) => {
|
|
170
178
|
const descs: FieldDesc[] = implicitId ? [{ n: "id", k: "key" }] : []
|
|
171
179
|
for (const [key, d] of Object.entries(fields)) {
|
|
172
|
-
|
|
180
|
+
// a file reads like a scalar: a query without a selection returns it (../files/db.ts makes it the relation select)
|
|
181
|
+
if (isFile(name, key)) descs.push({ n: key, k: "body" })
|
|
182
|
+
else if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
|
|
173
183
|
else if (d.kind === "struct") {
|
|
174
184
|
const sub = `${name}.${key}`
|
|
175
185
|
const inner: Record<string, FieldDef> = {}
|
|
@@ -204,21 +214,51 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
204
214
|
for (const name of [AUTH_SESSION_MODEL, AUTH_IDENTITY_MODEL]) {
|
|
205
215
|
if (name in own) throw new Error(`defineDb: "${name}" is the platform's own model (it comes with .withAuth) — name yours differently`)
|
|
206
216
|
}
|
|
217
|
+
if (FILE_MODEL in own) throw new Error(`defineDb: "${FILE_MODEL}" is the platform's own model (it comes with a t.file() field) — name yours differently`)
|
|
218
|
+
// the schema's file fields: each is, in the database, the reverse side of a reference on the platform's File
|
|
219
|
+
const fileList: FileField[] = []
|
|
220
|
+
for (const [name, m] of Object.entries(own)) {
|
|
221
|
+
for (const [key, f] of Object.entries(m.fields as Fields)) {
|
|
222
|
+
const def = (f as Field<any, any>).def
|
|
223
|
+
if (def.kind === "file") fileList.push({ model: name, key, array: def.array, owner: fileOwnerKey(name, key), ...(def.image ? { image: def.image } : {}) })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
207
226
|
// What is wrong with the project's own models is said HERE, at the definition, not at the first use:
|
|
208
227
|
// checked against a stand-in for the one platform model they may point at.
|
|
209
228
|
buildMeta({ ...own, [AUTH_SESSION_MODEL]: model({}) })
|
|
210
229
|
|
|
211
230
|
let authModel: string | null = null
|
|
212
|
-
let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null } | null = null
|
|
231
|
+
let sealed: { models: Record<string, Model>, schema: string, collections: Record<string, unknown>, auth: AuthBinding | null, files: FileField[] } | null = null
|
|
213
232
|
|
|
214
233
|
const transport = () => {
|
|
215
234
|
const t = options.transport ?? seam.__lecodesDbTransport
|
|
216
235
|
if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
|
|
217
236
|
return t
|
|
218
237
|
}
|
|
238
|
+
// A db with file fields runs every operation through the file layer: a write of a file field is the
|
|
239
|
+
// row's operation and the ones on File after it, in one transaction.
|
|
240
|
+
let files: ReturnType<typeof createFileLayer> | null = null
|
|
241
|
+
const exec = async (op: MarciOp): Promise<any> => {
|
|
242
|
+
if (!files) return transport().exec(op)
|
|
243
|
+
const plan = await files.plan(op, 0)
|
|
244
|
+
if (!plan.after.length) return files.result(plan.op, await transport().exec(plan.op))
|
|
245
|
+
return (await transport().batch([plan.op, ...plan.after]))[0]
|
|
246
|
+
}
|
|
247
|
+
const batch = async (ops: MarciOp[]): Promise<any[]> => {
|
|
248
|
+
if (!files) return transport().batch(ops)
|
|
249
|
+
// the project's operations keep their places (its `ref`s count them); what follows them goes after
|
|
250
|
+
const plans: Awaited<ReturnType<typeof files.plan>>[] = []
|
|
251
|
+
for (let index = 0; index < ops.length; index++) plans.push(await files.plan(ops[index], index))
|
|
252
|
+
const results = await transport().batch([...plans.map(p => p.op), ...plans.flatMap(p => p.after)])
|
|
253
|
+
return plans.map((p, index) => files!.result(p.op, results[index]))
|
|
254
|
+
}
|
|
219
255
|
const seal = () => {
|
|
220
256
|
if (sealed) return sealed
|
|
221
257
|
const models: Record<string, Model> = { ...own }
|
|
258
|
+
if (fileList.length) {
|
|
259
|
+
for (const f of fileList) models[f.model] = model({ ...(models[f.model].fields as Fields), [f.key]: t.many(FILE_MODEL, f.owner) })
|
|
260
|
+
models[FILE_MODEL] = model(fileFields(fileList))
|
|
261
|
+
}
|
|
222
262
|
if (authModel) {
|
|
223
263
|
models[AUTH_SESSION_MODEL] = model(sessionFields(authModel))
|
|
224
264
|
models[AUTH_IDENTITY_MODEL] = model(identityFields(authModel))
|
|
@@ -246,10 +286,21 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
246
286
|
for (const [key, value] of missing) out[key] = [...value]
|
|
247
287
|
return out
|
|
248
288
|
}
|
|
289
|
+
const isFile = (name: string, key: string) => fileList.some(f => f.model === name && f.key === key)
|
|
290
|
+
const meta = modelsMeta(metas, isFile)
|
|
291
|
+
if (fileList.length) {
|
|
292
|
+
const relations: Record<string, Record<string, string>> = {}
|
|
293
|
+
const scalars: Record<string, string[]> = {}
|
|
294
|
+
for (const [name, descs] of Object.entries(meta)) {
|
|
295
|
+
relations[name] = Object.fromEntries(descs.filter(d => d.m).map(d => [d.n, d.m!]))
|
|
296
|
+
scalars[name] = descs.filter(d => d.k === "key" || d.k === "body").map(d => d.n)
|
|
297
|
+
}
|
|
298
|
+
files = createFileLayer(fileList, relations, scalars)
|
|
299
|
+
}
|
|
249
300
|
// marcidb's own query builder over our metadata; the transport is the only host-specific piece
|
|
250
301
|
const { collection } = createQueryLayer({
|
|
251
|
-
models:
|
|
252
|
-
run:
|
|
302
|
+
models: meta,
|
|
303
|
+
run: exec,
|
|
253
304
|
// a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
|
|
254
305
|
prepareInsert: (name, raw) => {
|
|
255
306
|
const data = withDefaults(name, raw)
|
|
@@ -262,12 +313,13 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
262
313
|
models, schema: toMarci(metas), collections,
|
|
263
314
|
// the platform's models are reached by role, never as `db.session` / `db.identity`
|
|
264
315
|
auth: authModel ? { model: authModel, user: collection(authModel), session: collection(AUTH_SESSION_MODEL), identity: collection(AUTH_IDENTITY_MODEL) } : null,
|
|
316
|
+
files: fileList,
|
|
265
317
|
}
|
|
266
318
|
return sealed
|
|
267
319
|
}
|
|
268
320
|
|
|
269
321
|
const db: Record<string, unknown> = {
|
|
270
|
-
$transaction: async (ops: unknown[]) => { seal(); return
|
|
322
|
+
$transaction: async (ops: unknown[]) => { seal(); return batch(ops.map(opOf)) },
|
|
271
323
|
withAuth: ({ model: name }: { model: string }) => {
|
|
272
324
|
if (sealed) throw new Error("defineDb(...).withAuth(): this db has already been used — sign-in is part of the definition, write it on defineDb(...) itself")
|
|
273
325
|
if (authModel) throw new Error("defineDb(...).withAuth(): called twice on one db")
|
|
@@ -276,7 +328,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
276
328
|
for (const [key, f] of Object.entries(user.fields as Fields)) {
|
|
277
329
|
const d = (f as Field<any, any>).def
|
|
278
330
|
if (d.isId || key === "id") throw new Error(`defineDb(...).withAuth(): ${name} must keep the built-in id (it declares "${key}" as its key) — sessions and sign-in methods point at it`)
|
|
279
|
-
if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault) continue
|
|
331
|
+
if (d.kind === "many" || d.kind === "list" || d.optional || d.hasDefault || (d.kind === "file" && d.array)) continue
|
|
280
332
|
throw new Error(`defineDb(...).withAuth(): ${name}.${key} needs .optional() or .default(...) — the platform creates the row the first time someone signs in`)
|
|
281
333
|
}
|
|
282
334
|
authModel = name
|
|
@@ -288,6 +340,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
288
340
|
$schema: { get: () => seal().schema },
|
|
289
341
|
$models: { get: () => seal().models },
|
|
290
342
|
$auth: { get: () => seal().auth },
|
|
343
|
+
$files: { get: () => seal().files },
|
|
291
344
|
})
|
|
292
345
|
for (const name of Object.keys(own)) {
|
|
293
346
|
Object.defineProperty(db, uncap(name), { enumerable: true, get: () => seal().collections[name] })
|