@zerotal/flow 1.7.2 → 1.7.3
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/CHANGELOG.md +16 -2
- package/README.md +1 -1
- package/api-surface.md +10 -6
- package/package.json +6 -6
- package/src/Component.ts +128 -50
- package/src/client/bridge.ts +24 -4
- package/src/decorators.ts +1 -1
- package/src/dehydrate.ts +43 -0
- package/src/durable.ts +5 -2
- package/src/registry.ts +6 -2
- package/src/router.ts +7 -1
- package/src/synths/CollectionSynth.ts +17 -15
- package/src/synths/ModelSynth.ts +335 -35
package/CHANGELOG.md
CHANGED
|
@@ -13,7 +13,7 @@ change.
|
|
|
13
13
|
|
|
14
14
|
### Added
|
|
15
15
|
|
|
16
|
-
- **`@on` can name a channel per instance.** A listener's channel is read off the
|
|
16
|
+
- **`@on` can name a channel per instance.** A listener's channel is read off the _class_, so
|
|
17
17
|
a channel containing a record id could not be written at all. The guide showed
|
|
18
18
|
`@on("echo-private:issues.${this.issueId},CommentPosted")`, which is not interpolation —
|
|
19
19
|
it is a plain string, and the eleven characters `${this.issueId}` reached the browser
|
|
@@ -52,6 +52,20 @@ change.
|
|
|
52
52
|
|
|
53
53
|
### Fixed
|
|
54
54
|
|
|
55
|
+
- **A bound password field silently discarded every keystroke.** The client-writable set was
|
|
56
|
+
`fillable` minus `hidden`, which conflates two allow-lists that answer different questions:
|
|
57
|
+
`fillable`/`guarded` govern what may be _written_, `visible`/`hidden` govern what may be
|
|
58
|
+
_shown_. A password is in both — fillable because a user sets it, hidden because the stored
|
|
59
|
+
hash must never reach a page — so subtracting made it unwritable, and
|
|
60
|
+
`<input type="password" value={this.user.password} blur />` accepted typing and dropped it
|
|
61
|
+
on arrival. `hidden` is no longer subtracted. It is still never _sent_: the hash does not
|
|
62
|
+
leave the server, and the field arrives empty as it should. A hidden value the client
|
|
63
|
+
supplied now survives until save, and one the _server_ produced is never echoed back.
|
|
64
|
+
|
|
65
|
+
A half-typed secret is also stripped from the [durable snapshot](/docs/flow/lifecycle)
|
|
66
|
+
before it is persisted — that store is written server-side after every request and may be
|
|
67
|
+
backed by Redis, which is no place for a password in progress.
|
|
68
|
+
|
|
55
69
|
- **`<FileUpload>` never stopped saying "Uploading… 100%".** A successful upload left the
|
|
56
70
|
dropzone reporting progress forever. The bytes had gone up, the signed reference had come
|
|
57
71
|
back, and the bound property had been set — the only thing that failed was the component
|
|
@@ -94,7 +108,7 @@ change.
|
|
|
94
108
|
it has that this class does not.
|
|
95
109
|
|
|
96
110
|
- **The real-time validation example did not compile.** `docs/flow/decorators.md` showed
|
|
97
|
-
`<input value={this.email} flow:model.live />`. That is the
|
|
111
|
+
`<input value={this.email} flow:model.live />`. That is the _emitted_ directive, not the prop:
|
|
98
112
|
the `.` in an attribute name is a parse error in TSX (`TS1003`), so anyone copying it got a
|
|
99
113
|
build failure on a line the guide had just handed them. The prop is `live`; the note now says
|
|
100
114
|
so, and says why the compiled form cannot be written by hand.
|
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ All imported from `@zerotal/flow`.
|
|
|
114
114
|
| `@reactive` | A child prop the parent re-pushes whenever it changes, re-rendering the child. |
|
|
115
115
|
| `@modelable` | A two-way child prop — writes from the child flow back up to the parent. |
|
|
116
116
|
|
|
117
|
-
Action helpers available inside any exposed method include `this.flash()`, `this.redirect()`, `this.refresh()`,
|
|
117
|
+
Action helpers available inside any exposed method include `this.flash()`, `this.redirect()`, `this.refresh()`, ``this.$`…` ``, `this.download()`, `this.dispatch()`, `this.stream()`, and the `this.errors` proxy (`has`, `any`, `addError`, `resetValidation`). Lifecycle hooks: `setup`, `onBoot`, `onMount`, `onHydrate`, `onUpdating`/`onUpdated`, `onUpdate`, `onRendering`, `onRendered`, `onDehydrate`, `onError`.
|
|
118
118
|
|
|
119
119
|
## Built-in components
|
|
120
120
|
|
package/api-surface.md
CHANGED
|
@@ -9,7 +9,9 @@ class Component = {
|
|
|
9
9
|
new (): Component
|
|
10
10
|
static durable?: boolean | { ttl?: string; scope?: 'user' | 'session';}
|
|
11
11
|
static readonly __isFlowPage: true
|
|
12
|
+
static title?: string | ((component: never) => string)
|
|
12
13
|
static using: Compose
|
|
14
|
+
$: (strings: TemplateStringsArray, ...values: unknown[]) => void
|
|
13
15
|
$refresh: () => void
|
|
14
16
|
$set: (key: string, value: unknown) => void
|
|
15
17
|
_applyClientUpdate: (key: string, value: unknown, arrayKey?: string) => Promise<void>
|
|
@@ -31,10 +33,10 @@ class Component = {
|
|
|
31
33
|
_prevChildIds: string[]
|
|
32
34
|
_redirectStatus: number | null
|
|
33
35
|
_redirectUrl: string | null
|
|
36
|
+
_resolveTitle: () => string | null
|
|
34
37
|
_shouldRefresh: boolean
|
|
35
38
|
_streamSender: ((ref: string, content: string, replace: boolean) => void) | null
|
|
36
39
|
_taskSignal: AbortSignal | null
|
|
37
|
-
_titleValue: string | null
|
|
38
40
|
_validateField: (field: string) => Promise<void>
|
|
39
41
|
addError: (field: string, message: string) => void
|
|
40
42
|
bind: (key: string, optionValue?: string) => Record<string, unknown>
|
|
@@ -42,7 +44,7 @@ class Component = {
|
|
|
42
44
|
child: <C extends Component>(ChildClass: new () => C, opts?: { key?: string | number; props?: Partial<C>; lazy?: boolean; defer?: boolean; stream?: boolean; slots?: Record<string, string>;}) => Promise<HtmlNode>
|
|
43
45
|
clearDurable: () => void
|
|
44
46
|
client: (script: string) => void
|
|
45
|
-
currentUrl: (
|
|
47
|
+
currentUrl: (options?: CurrentUrlOptions) => string
|
|
46
48
|
dispatch: <K extends string>(name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
|
|
47
49
|
dispatchSelf: <K extends string>(name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
|
|
48
50
|
dispatchTo: <K extends string>(component: string, name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
|
|
@@ -51,7 +53,7 @@ class Component = {
|
|
|
51
53
|
flash: (message: string, optionsOrLevel?: FlashLevel | FlashOptions) => FlashBuilder
|
|
52
54
|
hasSlot: (name?: string) => boolean
|
|
53
55
|
layout: (page: HtmlNode) => HtmlNode | Promise<HtmlNode>
|
|
54
|
-
navigateCurrent: (
|
|
56
|
+
navigateCurrent: (options?: CurrentUrlOptions) => Promise<void>
|
|
55
57
|
onBoot: (_ctx?: HttpContext) => Promise<void>
|
|
56
58
|
onDehydrate: () => Promise<void>
|
|
57
59
|
onError: (error: Error) => Promise<void>
|
|
@@ -72,7 +74,6 @@ class Component = {
|
|
|
72
74
|
signal: AbortSignal
|
|
73
75
|
slot: (name?: string) => HtmlNode
|
|
74
76
|
stream: (ref: string, content: string, opts?: { replace?: boolean;}) => void
|
|
75
|
-
title: { (): string; (value: string): void;}
|
|
76
77
|
validate: (rulesOrForm?: ValidationRules | { __isFlowForm: true; _validateToErrors(): Record<string, string[]>;}) => Promise<void>
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -258,7 +259,7 @@ function Loading = (props: LoadingProps) => HtmlNode
|
|
|
258
259
|
|
|
259
260
|
function Modal = (props: ModalProps) => HtmlNode
|
|
260
261
|
|
|
261
|
-
function on = (eventName:
|
|
262
|
+
function on = (eventName: ListenerName) => (fn: unknown, context: ClassMethodDecoratorContext) => void
|
|
262
263
|
|
|
263
264
|
function Pager = (props: PagerProps) => HtmlNode
|
|
264
265
|
|
|
@@ -709,6 +710,7 @@ interface LegendProps = {
|
|
|
709
710
|
interface LinkProps = {
|
|
710
711
|
[key: string]: unknown
|
|
711
712
|
current?: boolean
|
|
713
|
+
down?: boolean
|
|
712
714
|
exact?: boolean
|
|
713
715
|
hover?: boolean
|
|
714
716
|
href: string
|
|
@@ -760,7 +762,7 @@ interface PageClassWithMeta = {
|
|
|
760
762
|
layout?: new () => Layout
|
|
761
763
|
models?: Record<string, typeof BaseModel>
|
|
762
764
|
name: string
|
|
763
|
-
title?: string
|
|
765
|
+
title?: string | ((component: never) => string)
|
|
764
766
|
}
|
|
765
767
|
|
|
766
768
|
interface PagerProps = {
|
|
@@ -1038,6 +1040,8 @@ type FlashPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' |
|
|
|
1038
1040
|
|
|
1039
1041
|
type HtmlNode = { html: string;}
|
|
1040
1042
|
|
|
1043
|
+
type ListenerName = (string & {}) | ((self: Record<string, any>) => string)
|
|
1044
|
+
|
|
1041
1045
|
type Mixin = (Base: TIn) => TOut
|
|
1042
1046
|
|
|
1043
1047
|
type PresenceChannel = string | ((self: Record<string, any>) => string)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/flow",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
"@alpinejs/morph": "^3.14.9",
|
|
48
48
|
"@alpinejs/persist": "^3.14.9",
|
|
49
49
|
"@alpinejs/resize": "^3.14.9",
|
|
50
|
-
"@zerotal/client": "1.7.
|
|
51
|
-
"@zerotal/core": "1.7.
|
|
52
|
-
"@zerotal/orm": "1.7.
|
|
53
|
-
"@zerotal/validator": "1.7.
|
|
50
|
+
"@zerotal/client": "1.7.3",
|
|
51
|
+
"@zerotal/core": "1.7.3",
|
|
52
|
+
"@zerotal/orm": "1.7.3",
|
|
53
|
+
"@zerotal/validator": "1.7.3",
|
|
54
54
|
"alpinejs": "^3.14.9",
|
|
55
55
|
"typescript": "^5.8.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@zerotal/broadcasting": "1.7.
|
|
58
|
+
"@zerotal/broadcasting": "1.7.3",
|
|
59
59
|
"@types/alpinejs": "^3.13.10",
|
|
60
60
|
"@types/alpinejs__anchor": "^3.13.1",
|
|
61
61
|
"@types/alpinejs__collapse": "^3.13.4",
|
package/src/Component.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
FlashActionStyle,
|
|
8
8
|
FlashCallback,
|
|
9
9
|
} from "./types.ts";
|
|
10
|
-
import type
|
|
10
|
+
import { buildUrlWithQuery, type CurrentUrlOptions } from "./client/url.ts";
|
|
11
11
|
import {
|
|
12
12
|
getExposedProps,
|
|
13
13
|
getLockedProps,
|
|
@@ -22,6 +22,7 @@ import type { Schema, FieldRuleDefinition } from "@zerotal/validator";
|
|
|
22
22
|
import type { ValidateBuilder } from "./decorators.ts";
|
|
23
23
|
import { ValidationError } from "./validation.ts";
|
|
24
24
|
import type { ValidationRules } from "./validation.ts";
|
|
25
|
+
import { _applyWireValues, _isRegisteredModel } from "./synths/ModelSynth.ts";
|
|
25
26
|
import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
|
|
26
27
|
import { toScriptJson } from "./utils.ts";
|
|
27
28
|
import { _compose } from "./mixins.ts";
|
|
@@ -174,6 +175,31 @@ function _isProduction(): boolean {
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/** Classes already warned about, so a hundred-row list logs once. */
|
|
178
|
+
/**
|
|
179
|
+
* Encode one interpolated value as a JS literal for a client expression.
|
|
180
|
+
*
|
|
181
|
+
* The scripts are evaluated with `Alpine.evaluate(rootEl, script)`, so each value has to
|
|
182
|
+
* arrive as source text. JSON is a subset of JS expression syntax for every shape that
|
|
183
|
+
* survives `JSON.stringify`, which is what makes this exact rather than approximate — and it
|
|
184
|
+
* is what removes the old `@security` note from `client()`, where the caller built the string
|
|
185
|
+
* and owned the escaping.
|
|
186
|
+
*
|
|
187
|
+
* The cases JSON alone gets wrong are handled explicitly: `undefined` and functions stringify
|
|
188
|
+
* to nothing, `NaN` / `Infinity` become `null`, and `BigInt` throws.
|
|
189
|
+
*/
|
|
190
|
+
function _encodeClientValue(value: unknown): string {
|
|
191
|
+
if (value === undefined) return "undefined";
|
|
192
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
193
|
+
if (typeof value === "number" && !Number.isFinite(value)) return String(value);
|
|
194
|
+
|
|
195
|
+
const json = JSON.stringify(value);
|
|
196
|
+
if (json === undefined) return "undefined"; // functions, symbols
|
|
197
|
+
|
|
198
|
+
// U+2028/U+2029 are legal inside a JSON string and legal in a JS string literal only since
|
|
199
|
+
// ES2019. Escaping them costs nothing and keeps the output safe in any JS context.
|
|
200
|
+
return json.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
201
|
+
}
|
|
202
|
+
|
|
177
203
|
const _warnedKeyless = new Set<string>();
|
|
178
204
|
|
|
179
205
|
function _warnKeylessChild(className: string): void {
|
|
@@ -348,19 +374,6 @@ export interface FlashBuilder {
|
|
|
348
374
|
|
|
349
375
|
// ── Component ──────────────────────────────────────────────────────────────────────
|
|
350
376
|
|
|
351
|
-
/**
|
|
352
|
-
* Client-only URL helpers compile to `$flow.*` client expressions and never run on the
|
|
353
|
-
* server. Reaching this means the call executed server-side — i.e. on a page the compiler
|
|
354
|
-
* couldn't statically compile (so it fell back to the runtime renderer), or in a server action.
|
|
355
|
-
*/
|
|
356
|
-
function _clientOnlyUrlHelper(method: string): never {
|
|
357
|
-
throw new Error(
|
|
358
|
-
`[Flow] this.${method}() is client-only — the compiler rewrites it to \`$flow.${method}\`. ` +
|
|
359
|
-
`It ran on the server, which means this page couldn't be statically compiled (e.g. it ` +
|
|
360
|
-
`uses a construct the AOT compiler bails on) or you called it from a server action.`,
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
377
|
/**
|
|
365
378
|
* Base class for Flow pages — reactive server-side components rendered over a WebSocket.
|
|
366
379
|
*
|
|
@@ -555,9 +568,6 @@ export abstract class Component {
|
|
|
555
568
|
/** File downloads queued by download(). Sent as DownloadFrames. @internal */
|
|
556
569
|
_downloads: Array<{ filename: string; content: string; mime: string }> = [];
|
|
557
570
|
|
|
558
|
-
/** Dynamic document.title set by title(). Included in the patch frame. @internal */
|
|
559
|
-
_titleValue: string | null = null;
|
|
560
|
-
|
|
561
571
|
/** This component's id — set by the route handler (SSR) or the WS dispatcher. @internal */
|
|
562
572
|
_flowId = "";
|
|
563
573
|
|
|
@@ -623,6 +633,26 @@ export abstract class Component {
|
|
|
623
633
|
*
|
|
624
634
|
* @category State & exposure
|
|
625
635
|
*/
|
|
636
|
+
/**
|
|
637
|
+
* The page's document title — a string, or a function of the component.
|
|
638
|
+
*
|
|
639
|
+
* ```ts
|
|
640
|
+
* static title = "Search";
|
|
641
|
+
* static title = (c: SearchPage) => (c.query ? `Search: ${c.query}` : "Search");
|
|
642
|
+
* ```
|
|
643
|
+
*
|
|
644
|
+
* Resolved on the server for every render and every patch, so the function form tracks
|
|
645
|
+
* state without the page doing anything: change `query` in an action and the tab title
|
|
646
|
+
* follows. Only the resolved string is ever sent to the browser.
|
|
647
|
+
*
|
|
648
|
+
* Declared here rather than left to convention. It was read structurally off the
|
|
649
|
+
* constructor, which meant no type, no autocomplete, and no entry in the API surface for
|
|
650
|
+
* something the docs already told people to write.
|
|
651
|
+
*
|
|
652
|
+
* @category Rendering
|
|
653
|
+
*/
|
|
654
|
+
static title?: string | ((component: never) => string);
|
|
655
|
+
|
|
626
656
|
static durable?: boolean | { ttl?: string; scope?: "user" | "session" };
|
|
627
657
|
|
|
628
658
|
/** Set by {@link clearDurable}; the dispatcher deletes the durable entry after the request. @internal */
|
|
@@ -1039,7 +1069,9 @@ export abstract class Component {
|
|
|
1039
1069
|
* this.client(`$refs.titleInput.focus()`);
|
|
1040
1070
|
* this.client(`$el.querySelector('.toast').classList.add('visible')`);
|
|
1041
1071
|
*
|
|
1042
|
-
* @security Never interpolate unescaped user input into the expression string.
|
|
1072
|
+
* @security Never interpolate unescaped user input into the expression string. Prefer the
|
|
1073
|
+
* {@link $} tagged template, which encodes interpolated values for you.
|
|
1074
|
+
* @deprecated Use the `$` tagged template: ``this.$`$refs.titleInput.focus()` ``.
|
|
1043
1075
|
* @category Actions
|
|
1044
1076
|
*/
|
|
1045
1077
|
client(script: string): void {
|
|
@@ -1047,27 +1079,33 @@ export abstract class Component {
|
|
|
1047
1079
|
}
|
|
1048
1080
|
|
|
1049
1081
|
/**
|
|
1050
|
-
*
|
|
1051
|
-
* @category Actions
|
|
1052
|
-
*/
|
|
1053
|
-
title(): string;
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Update `document.title` in the browser after this action completes.
|
|
1082
|
+
* Run a client-side expression from a server action, interpolating values safely.
|
|
1057
1083
|
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1084
|
+
* A tagged template, so each `${…}` is encoded as a JS literal rather than concatenated
|
|
1085
|
+
* into the source by the caller:
|
|
1086
|
+
*
|
|
1087
|
+
* ```ts
|
|
1088
|
+
* this.$`console.log(${this.search})`;
|
|
1089
|
+
* this.$`$refs.title.focus()`;
|
|
1090
|
+
* this.$`$el.dataset.count = ${this.items.length}`;
|
|
1091
|
+
* ```
|
|
1092
|
+
*
|
|
1093
|
+
* That is the whole reason it replaces {@link client}. Building the expression by hand meant
|
|
1094
|
+
* the caller owned the escaping, and a value containing a quote — a name, a search term —
|
|
1095
|
+
* produced either a syntax error or something worse.
|
|
1096
|
+
*
|
|
1097
|
+
* Runs in the component root's Alpine context after the action's DOM patch, exactly as
|
|
1098
|
+
* {@link client} does: `$el`, `$refs` and the `$flow` magics are all in scope, and multiple
|
|
1099
|
+
* calls execute in order after the single round trip.
|
|
1060
1100
|
*
|
|
1061
1101
|
* @category Actions
|
|
1062
1102
|
*/
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
if (value === undefined) {
|
|
1068
|
-
return this._titleValue ?? (this.constructor as { title?: string }).title ?? "";
|
|
1103
|
+
$(strings: TemplateStringsArray, ...values: unknown[]): void {
|
|
1104
|
+
let script = strings[0] ?? "";
|
|
1105
|
+
for (let i = 0; i < values.length; i++) {
|
|
1106
|
+
script += _encodeClientValue(values[i]) + (strings[i + 1] ?? "");
|
|
1069
1107
|
}
|
|
1070
|
-
this.
|
|
1108
|
+
this._clientScripts.push(script);
|
|
1071
1109
|
}
|
|
1072
1110
|
|
|
1073
1111
|
// ── Validation ────────────────────────────────────────────────────────────
|
|
@@ -1286,29 +1324,39 @@ export abstract class Component {
|
|
|
1286
1324
|
// `$flow` is typed globally (see augment.ts); `$flow.store` is typed by `FlowStore`.
|
|
1287
1325
|
|
|
1288
1326
|
/**
|
|
1289
|
-
* Build a URL from the current one with merged query params
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1327
|
+
* Build a URL from the current one with merged query params. Listed params are added or
|
|
1328
|
+
* updated, unlisted ones are preserved, and a `null` / `undefined` / `""` value removes one.
|
|
1329
|
+
*
|
|
1330
|
+
* Works on both render paths, which is the point. Written in JSX —
|
|
1331
|
+
* `href={this.currentUrl({ query: { page: 2 } })}` — the compiler rewrites it to
|
|
1332
|
+
* `$flow.currentUrl` and it runs in the browser against `location.href`. Reached from server
|
|
1333
|
+
* code it does the same work against the current request's URL, through the same pure
|
|
1334
|
+
* {@link buildUrlWithQuery} helper, so the two paths cannot drift.
|
|
1335
|
+
*
|
|
1336
|
+
* It used to throw on the server, on the reasoning that the compiler always rewrote it. The
|
|
1337
|
+
* compiler bails for reasons that have nothing to do with this call — one `__()` anywhere in
|
|
1338
|
+
* the page is enough — and the page then renders through the runtime renderer, where this
|
|
1339
|
+
* executes server-side. A translated page with a paginated link crashed.
|
|
1294
1340
|
*
|
|
1295
|
-
* @throws {Error} when executed on the server (it is meant to be compiled to `$flow.currentUrl`).
|
|
1296
1341
|
* @category Navigation & redirects
|
|
1297
1342
|
*/
|
|
1298
|
-
currentUrl(
|
|
1299
|
-
return
|
|
1343
|
+
currentUrl(options: CurrentUrlOptions = {}): string {
|
|
1344
|
+
return buildUrlWithQuery(request().url.href, options);
|
|
1300
1345
|
}
|
|
1301
1346
|
|
|
1302
1347
|
/**
|
|
1303
|
-
* Build the URL as {@link currentUrl} does, then
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1348
|
+
* Build the URL as {@link currentUrl} does, then go to it.
|
|
1349
|
+
*
|
|
1350
|
+
* In a JSX handler — `onClick={() => this.navigateCurrent({ query: { page: 2 } })}` — the
|
|
1351
|
+
* compiler rewrites it to `$flow.navigateCurrent` and the browser SPA-navigates. Reached from
|
|
1352
|
+
* server code it queues a redirect to the same URL: a different mechanism for the same
|
|
1353
|
+
* intent, and the one the server has. See {@link currentUrl} for why both paths must work.
|
|
1306
1354
|
*
|
|
1307
|
-
* @throws {Error} when executed on the server (it is meant to be compiled to `$flow.navigateCurrent`).
|
|
1308
1355
|
* @category Navigation & redirects
|
|
1309
1356
|
*/
|
|
1310
|
-
navigateCurrent(
|
|
1311
|
-
|
|
1357
|
+
navigateCurrent(options: CurrentUrlOptions = {}): Promise<void> {
|
|
1358
|
+
this.redirect(buildUrlWithQuery(request().url.href, options));
|
|
1359
|
+
return Promise.resolve();
|
|
1312
1360
|
}
|
|
1313
1361
|
|
|
1314
1362
|
// ── File downloads ────────────────────────────────────────────────────────
|
|
@@ -1685,6 +1733,21 @@ export abstract class Component {
|
|
|
1685
1733
|
(cur as { fill(v: Record<string, unknown>): void }).fill(value as Record<string, unknown>);
|
|
1686
1734
|
return;
|
|
1687
1735
|
}
|
|
1736
|
+
// Model: same reasoning as the form above — fill the hydrated instance rather than
|
|
1737
|
+
// replacing it with the plain object the client sent, so its class, methods and dirty
|
|
1738
|
+
// tracking survive a `value={this.user.name}` edit. `_applyWireValues` keeps only
|
|
1739
|
+
// `fillable` minus `hidden`, so a field the model never offered is ignored rather than
|
|
1740
|
+
// written or thrown over.
|
|
1741
|
+
if (
|
|
1742
|
+
cur &&
|
|
1743
|
+
typeof cur === "object" &&
|
|
1744
|
+
_isRegisteredModel(cur) &&
|
|
1745
|
+
value &&
|
|
1746
|
+
typeof value === "object"
|
|
1747
|
+
) {
|
|
1748
|
+
_applyWireValues(cur, value);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1688
1751
|
// Signed upload references become TemporaryUploadedFile instances (signature verified);
|
|
1689
1752
|
// all other values pass through unchanged.
|
|
1690
1753
|
(this as Record<string, unknown>)[key] = resolveUploadValue(value);
|
|
@@ -1737,6 +1800,22 @@ export abstract class Component {
|
|
|
1737
1800
|
|
|
1738
1801
|
// ── Effect draining (called by WS handler after action) ──────────────────
|
|
1739
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* The title to send with this frame — `static title` resolved against this component.
|
|
1805
|
+
*
|
|
1806
|
+
* Resolved per frame rather than once, which is what lets the function form track state:
|
|
1807
|
+
* change what it reads and the next patch carries the new title.
|
|
1808
|
+
*
|
|
1809
|
+
* @internal
|
|
1810
|
+
*/
|
|
1811
|
+
_resolveTitle(): string | null {
|
|
1812
|
+
const declared = (this.constructor as { title?: unknown }).title;
|
|
1813
|
+
if (typeof declared === "function") {
|
|
1814
|
+
return String((declared as (c: unknown) => unknown)(this));
|
|
1815
|
+
}
|
|
1816
|
+
return typeof declared === "string" ? declared : null;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1740
1819
|
/** @internal — read by the WS handler after action execution. */
|
|
1741
1820
|
_drainEffects(): FlowEffects {
|
|
1742
1821
|
const effects: FlowEffects = {
|
|
@@ -1747,12 +1826,11 @@ export abstract class Component {
|
|
|
1747
1826
|
errors: this._errors,
|
|
1748
1827
|
events: this._events.splice(0),
|
|
1749
1828
|
downloads: this._downloads.splice(0),
|
|
1750
|
-
title: this.
|
|
1829
|
+
title: this._resolveTitle(),
|
|
1751
1830
|
};
|
|
1752
1831
|
this._redirectUrl = null;
|
|
1753
1832
|
this._redirectStatus = null;
|
|
1754
1833
|
this._shouldRefresh = false;
|
|
1755
|
-
this._titleValue = null;
|
|
1756
1834
|
// _errors intentionally NOT reset here: errors persist across actions so the
|
|
1757
1835
|
// client continues showing field messages after e.g. a failed save that then
|
|
1758
1836
|
// dispatches a flash. Call resetValidation() or a passing validate() to clear.
|
package/src/client/bridge.ts
CHANGED
|
@@ -2900,12 +2900,32 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
|
|
|
2900
2900
|
// the animation ends at the right offset instead of sliding there.
|
|
2901
2901
|
_applyScroll(scrollIntent, _hashOf(href));
|
|
2902
2902
|
};
|
|
2903
|
-
|
|
2904
|
-
|
|
2903
|
+
// The return value is typed here rather than cast at the call site, so reading
|
|
2904
|
+
// the transition's promises below costs no new assertion.
|
|
2905
|
+
const _startVT = (
|
|
2906
|
+
document as unknown as {
|
|
2907
|
+
startViewTransition?: (cb: () => void) => {
|
|
2908
|
+
ready?: Promise<unknown>;
|
|
2909
|
+
finished?: Promise<unknown>;
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
).startViewTransition;
|
|
2905
2913
|
const _reduceMotion =
|
|
2906
2914
|
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2907
|
-
if (typeof _startVT === "function" && !_reduceMotion)
|
|
2908
|
-
|
|
2915
|
+
if (typeof _startVT === "function" && !_reduceMotion) {
|
|
2916
|
+
const _vt = _startVT.call(document, _swap);
|
|
2917
|
+
// A navigation that starts while a transition is still running makes the
|
|
2918
|
+
// browser skip the older one and reject its `ready` and `finished` promises
|
|
2919
|
+
// with an AbortError. Nothing has gone wrong — the swap itself already ran
|
|
2920
|
+
// inside the callback — but an unhandled rejection reaches `window.onerror`
|
|
2921
|
+
// and whatever error tracker the app has wired to it. Clicking through links
|
|
2922
|
+
// faster than the animation reported a stream of errors describing an
|
|
2923
|
+
// animation the browser skipped on purpose.
|
|
2924
|
+
void _vt?.ready?.catch(() => {});
|
|
2925
|
+
void _vt?.finished?.catch(() => {});
|
|
2926
|
+
} else {
|
|
2927
|
+
_swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
|
|
2928
|
+
}
|
|
2909
2929
|
|
|
2910
2930
|
if (push) _pushHistoryEntry(href);
|
|
2911
2931
|
const incomingTitle = doc.querySelector("title");
|
package/src/decorators.ts
CHANGED
|
@@ -970,7 +970,7 @@ export function validate(
|
|
|
970
970
|
*
|
|
971
971
|
* @category Realtime
|
|
972
972
|
*/
|
|
973
|
-
export type ListenerName = LooseEventName | ((self: Record<string, any>) => string);
|
|
973
|
+
export type ListenerName = LooseEventName | ((self: Record<string, any>) => string); // eslint-disable-line @typescript-eslint/no-explicit-any -- `unknown` would break `self.issue.id`
|
|
974
974
|
|
|
975
975
|
/**
|
|
976
976
|
* Binds a method as a cross-component (realtime) event listener. Auto-exposes the method.
|
package/src/dehydrate.ts
CHANGED
|
@@ -222,6 +222,49 @@ export async function hydrate<T extends Component>(
|
|
|
222
222
|
return page;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* A copy of `snapshot` with client-supplied hidden values removed, re-signed.
|
|
227
|
+
*
|
|
228
|
+
* A hidden field the user typed — a new password — rides in the snapshot so it survives until
|
|
229
|
+
* they save. That is fine on the wire, where it goes back to the browser that produced it,
|
|
230
|
+
* and wrong in the durable store, which writes the whole snapshot server-side after every
|
|
231
|
+
* request and may be backed by Redis. A half-typed password should not be waiting there for
|
|
232
|
+
* anyone, and resuming into one is not what durable is for.
|
|
233
|
+
*
|
|
234
|
+
* Re-signed rather than edited in place: the checksum covers `data`, so a strip without a
|
|
235
|
+
* fresh signature would produce a snapshot that fails its own verification on restore.
|
|
236
|
+
*
|
|
237
|
+
* @internal
|
|
238
|
+
*/
|
|
239
|
+
export function stripPendingSecrets(snapshot: Snapshot): Snapshot {
|
|
240
|
+
let touched = false;
|
|
241
|
+
const data: SnapshotData = {};
|
|
242
|
+
|
|
243
|
+
for (const [prop, [value, meta]] of Object.entries(snapshot.data)) {
|
|
244
|
+
const pending = meta["p"];
|
|
245
|
+
if (
|
|
246
|
+
!Array.isArray(pending) ||
|
|
247
|
+
pending.length === 0 ||
|
|
248
|
+
value === null ||
|
|
249
|
+
typeof value !== "object"
|
|
250
|
+
) {
|
|
251
|
+
data[prop] = [value, meta];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
touched = true;
|
|
255
|
+
const cleanValue: Record<string, unknown> = { ...(value as Record<string, unknown>) };
|
|
256
|
+
for (const key of pending as string[]) delete cleanValue[key];
|
|
257
|
+
const cleanMeta = { ...meta };
|
|
258
|
+
delete cleanMeta["p"];
|
|
259
|
+
data[prop] = [cleanValue, cleanMeta];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!touched) return snapshot;
|
|
263
|
+
|
|
264
|
+
const stripped = { ...snapshot, data };
|
|
265
|
+
return { ...stripped, checksum: _sign(_signingPayload(stripped)) };
|
|
266
|
+
}
|
|
267
|
+
|
|
225
268
|
/** Verify a snapshot's HMAC against the current APP_KEY. Public so durable-snapshot
|
|
226
269
|
* restore can validate a stored snapshot before trusting it (a rotated key → false). */
|
|
227
270
|
export function verifySnapshot(snapshot: Snapshot): boolean {
|
package/src/durable.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { Component } from "./Component.ts";
|
|
14
14
|
import type { Snapshot } from "./types.ts";
|
|
15
15
|
import type { HttpContext } from "@zerotal/core";
|
|
16
|
-
import { verifySnapshot, applySnapshotData } from "./dehydrate.ts";
|
|
16
|
+
import { verifySnapshot, applySnapshotData, stripPendingSecrets } from "./dehydrate.ts";
|
|
17
17
|
|
|
18
18
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
19
19
|
|
|
@@ -221,7 +221,10 @@ export async function persistDurable(
|
|
|
221
221
|
await _store.delete(key);
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
|
-
|
|
224
|
+
// Never persist a hidden field the client is part-way through typing — see
|
|
225
|
+
// `stripPendingSecrets`. The wire may carry it back to the browser that produced it; a
|
|
226
|
+
// server-side store, possibly Redis, may not hold it.
|
|
227
|
+
await _store.set(key, stripPendingSecrets(snapshot), cfg.ttlMs);
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
/**
|
package/src/registry.ts
CHANGED
|
@@ -107,8 +107,12 @@ export { getRenderlessMethods };
|
|
|
107
107
|
export interface PageClassWithMeta {
|
|
108
108
|
new (): Component;
|
|
109
109
|
name: string;
|
|
110
|
-
/**
|
|
111
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Sets the HTML `<title>` for this page — a string, or a function of the component
|
|
112
|
+
* instance. Resolve it with the component's `_resolveTitle()` rather than reading it
|
|
113
|
+
* directly, so both forms are handled in one place.
|
|
114
|
+
*/
|
|
115
|
+
title?: string | ((component: never) => string);
|
|
112
116
|
/** Raw HTML to inject into <head> for this page. */
|
|
113
117
|
head?: string;
|
|
114
118
|
/** Optional model associations for auto-registration. */
|
package/src/router.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { PageClassWithMeta } from "./registry.ts";
|
|
|
9
9
|
import { registerPage } from "./registry.ts";
|
|
10
10
|
import { dehydrate, warnIfLarge } from "./dehydrate.ts";
|
|
11
11
|
import { _renderFlowPage } from "./jsx-runtime.ts";
|
|
12
|
+
import { escapeHtml } from "@zerotal/core/helpers";
|
|
12
13
|
import { populatePresence } from "./presence.ts";
|
|
13
14
|
import { populateShared } from "./shared.ts";
|
|
14
15
|
import { restoreDurable, persistDurable } from "./durable.ts";
|
|
@@ -220,7 +221,12 @@ export function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
|
|
|
220
221
|
bodyContent = flowRoot.html;
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
|
|
224
|
+
// Through the component, not `PageClass.title` directly: `static title` may be a
|
|
225
|
+
// function of the component, and reading the raw static would render its source.
|
|
226
|
+
// Escaped because a resolved title can carry state — a search term reaching
|
|
227
|
+
// `<title>` unescaped is an injection, and the old direct interpolation had none.
|
|
228
|
+
const resolvedTitle = page._resolveTitle();
|
|
229
|
+
const titleTag = resolvedTitle ? `<title>${escapeHtml(resolvedTitle)}</title>` : "";
|
|
224
230
|
// Component head takes precedence; layout head provides global resources.
|
|
225
231
|
const headExtra = [PageClass.head ?? "", layoutHead].filter(Boolean).join("\n ");
|
|
226
232
|
|
|
@@ -5,15 +5,10 @@
|
|
|
5
5
|
// registered model, so plain arrays fall through to the recursive serializer.
|
|
6
6
|
import type { BaseModel } from "@zerotal/orm";
|
|
7
7
|
import { registerSynth } from "./index.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { _isModel, _modelKey, _resolveModel } from "./ModelSynth.ts";
|
|
9
9
|
|
|
10
10
|
function isModelOf(value: unknown, ctor: typeof BaseModel): boolean {
|
|
11
|
-
return (
|
|
12
|
-
typeof value === "object" &&
|
|
13
|
-
value !== null &&
|
|
14
|
-
(value as object).constructor === ctor &&
|
|
15
|
-
typeof (value as Record<string, unknown>)["id"] !== "undefined"
|
|
16
|
-
);
|
|
11
|
+
return _isModel(value) && (value as object).constructor === ctor;
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
registerSynth({
|
|
@@ -22,11 +17,11 @@ registerSynth({
|
|
|
22
17
|
match(value): value is BaseModel[] {
|
|
23
18
|
if (!Array.isArray(value) || value.length === 0) return false;
|
|
24
19
|
const first = value[0] as unknown;
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
20
|
+
// Registration is not required: an unregistered model would fall through to the generic
|
|
21
|
+
// serializer, which never calls `toJSON()` and so publishes every column. Same reasoning
|
|
22
|
+
// as ModelSynth's `match`.
|
|
23
|
+
if (!_isModel(first)) return false;
|
|
28
24
|
const ctor = (first as object).constructor as typeof BaseModel;
|
|
29
|
-
if (!_modelClassToName.has(ctor)) return false;
|
|
30
25
|
// Only claim a homogeneous array. A mixed array would be dehydrated under a
|
|
31
26
|
// single class tag and silently lose the odd-one-out on hydrate, so let it
|
|
32
27
|
// fall through to the recursive serializer, which handles each model via
|
|
@@ -36,18 +31,25 @@ registerSynth({
|
|
|
36
31
|
|
|
37
32
|
dehydrate(value: BaseModel[], meta) {
|
|
38
33
|
const ctor = (value[0] as object).constructor as typeof BaseModel;
|
|
39
|
-
|
|
34
|
+
const key = _modelKey(ctor);
|
|
35
|
+
if (!key) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[Flow] ${ctor.name} has no table name, so it cannot be sent across the wire. ` +
|
|
38
|
+
`Give it @table("…") — every model in app/models has one.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
meta["class"] = key;
|
|
40
42
|
return value.map((m) => (m as unknown as Record<string, unknown>)["id"]);
|
|
41
43
|
},
|
|
42
44
|
|
|
43
45
|
async hydrate(data, meta) {
|
|
44
46
|
const cls = meta["class"] as string | undefined;
|
|
45
47
|
if (!cls) throw new Error("[Flow] CollectionSynth: missing class in meta");
|
|
46
|
-
const Model =
|
|
48
|
+
const Model = _resolveModel(cls);
|
|
47
49
|
if (!Model) {
|
|
48
50
|
throw new Error(
|
|
49
|
-
`[Flow]
|
|
50
|
-
`
|
|
51
|
+
`[Flow] No model maps to "${cls}". Models live in app/models, where they are ` +
|
|
52
|
+
`discovered automatically — check the class is there and its @table matches.`,
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
const ids = data as unknown[];
|
package/src/synths/ModelSynth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BaseModel } from "@zerotal/orm";
|
|
2
|
+
import { modelsByName } from "@zerotal/orm";
|
|
2
3
|
import { registerSynth } from "./index.ts";
|
|
3
4
|
|
|
4
5
|
// Models register themselves here when their class is imported.
|
|
@@ -12,67 +13,366 @@ export const _modelRegistry = new Map<string, typeof BaseModel>();
|
|
|
12
13
|
export const _modelClassToName = new Map<typeof BaseModel, string>();
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
* across the wire: a model prop is dehydrated to its `id` (tagged with this name)
|
|
17
|
-
* and hydrated back by re-fetching via `findOrFail`.
|
|
16
|
+
* Associate a model class with an explicit wire name, overriding its table name.
|
|
18
17
|
*
|
|
19
18
|
* @remarks
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* framework registers them when the page is registered.
|
|
19
|
+
* **Not part of the normal path.** Models live in `app/models` and are discovered there, and
|
|
20
|
+
* a model travels under its table name — already declared, and immune to minification. There
|
|
21
|
+
* is nothing to register.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* This exists for the framework's own wiring: a component's `static models` map routes
|
|
24
|
+
* through it, and it takes precedence over the table name when both are present.
|
|
27
25
|
*
|
|
28
|
-
* @
|
|
29
|
-
*
|
|
30
|
-
* class ShowPost extends Component {
|
|
31
|
-
* static models = { Post }; // auto-calls registerFlowModel("Post", Post)
|
|
32
|
-
* @locked post!: Post; // survives the round-trip as a rehydrated model
|
|
33
|
-
* }
|
|
26
|
+
* @param name - The wire name recorded in the snapshot's `meta.class`.
|
|
27
|
+
* @param ModelClass - The `BaseModel` subclass to associate with `name`.
|
|
34
28
|
*
|
|
35
|
-
*
|
|
36
|
-
* registerFlowModel("Post", Post);
|
|
37
|
-
* ```
|
|
29
|
+
* @internal
|
|
38
30
|
*/
|
|
39
31
|
export function registerFlowModel(name: string, ModelClass: typeof BaseModel): void {
|
|
40
32
|
_modelRegistry.set(name, ModelClass);
|
|
41
33
|
_modelClassToName.set(ModelClass, name);
|
|
42
34
|
}
|
|
43
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Is this value an ORM model?
|
|
38
|
+
*
|
|
39
|
+
* Duck-typed, because importing `BaseModel` as a value here would be circular. A model class
|
|
40
|
+
* always carries a `table` string (set by `@table("…")` or inflected from the class name) and
|
|
41
|
+
* every instance has `toJSON` — together specific enough to tell a model from a plain object.
|
|
42
|
+
*
|
|
43
|
+
* **This deliberately does not require registration.** It used to, and an unregistered model
|
|
44
|
+
* fell through to the generic serializer, which walks properties directly and never calls
|
|
45
|
+
* `toJSON()` — so `hidden` was bypassed and every column, password hashes included, was
|
|
46
|
+
* written into the page. It failed on the second interaction and never the first, and
|
|
47
|
+
* `FlowTest` does not round-trip through a synth, so only a browser ever saw it. A missing
|
|
48
|
+
* declaration must not be able to cause that.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export function _isModel(value: unknown): value is BaseModel {
|
|
53
|
+
if (typeof value !== "object" || value === null) return false;
|
|
54
|
+
const ctor = (value as object).constructor as { table?: unknown } | undefined;
|
|
55
|
+
if (typeof ctor !== "function") return false;
|
|
56
|
+
if (typeof (ctor as { table?: unknown }).table !== "string") return false;
|
|
57
|
+
return typeof Reflect.get(value, "toJSON") === "function";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The stable key a model travels under.
|
|
62
|
+
*
|
|
63
|
+
* The table name, which the app already declares and a minifier cannot touch — unlike
|
|
64
|
+
* `constructor.name`, which is exactly what `Post` becomes `n` in a production build. An
|
|
65
|
+
* explicit {@link registerFlowModel} name still wins, for a model that wants one.
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export function _modelKey(ctor: typeof BaseModel): string | undefined {
|
|
70
|
+
const explicit = _modelClassToName.get(ctor);
|
|
71
|
+
if (explicit) return explicit;
|
|
72
|
+
const table: unknown = ctor.table;
|
|
73
|
+
return typeof table === "string" && table.length > 0 ? table : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find the class a key names — an explicit registration first, then the ORM's own registry,
|
|
78
|
+
* which `app/models` auto-discovery populates for every model in the app.
|
|
79
|
+
*
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
export function _resolveModel(key: string): typeof BaseModel | undefined {
|
|
83
|
+
const explicit = _modelRegistry.get(key);
|
|
84
|
+
if (explicit) return explicit;
|
|
85
|
+
|
|
86
|
+
for (const ctor of modelsByName.values()) {
|
|
87
|
+
if (Reflect.get(ctor, "table") === key) return ctor as typeof BaseModel;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const _wireFieldCache = new WeakMap<typeof BaseModel, readonly string[]>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convert a model's `toJSON()` output so any nested model in it is its own `toJSON()` too.
|
|
96
|
+
*
|
|
97
|
+
* `BaseModel.toJSON()` assigns relations by reference — `out[key] = this[key]` — so a loaded
|
|
98
|
+
* `post.author` comes out as a live `User`. In practice its `hidden` list still applied,
|
|
99
|
+
* because whatever stringified the snapshot honoured the `toJSON` contract. That is an
|
|
100
|
+
* implicit guarantee sitting under a security property, and it holds only for as long as
|
|
101
|
+
* every path to the wire goes through `JSON.stringify`.
|
|
102
|
+
*
|
|
103
|
+
* Doing it here makes it explicit: what leaves this synth is data, and every model in it has
|
|
104
|
+
* already answered for its own hidden columns.
|
|
105
|
+
*
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export function _deepToJson(value: unknown, seen = new WeakSet<object>()): unknown {
|
|
109
|
+
if (value === null || typeof value !== "object") return value;
|
|
110
|
+
if (seen.has(value)) return undefined; // a relation cycle (post.author.posts)
|
|
111
|
+
seen.add(value);
|
|
112
|
+
|
|
113
|
+
if (Array.isArray(value)) return value.map((v) => _deepToJson(v, seen));
|
|
114
|
+
|
|
115
|
+
if (_isModel(value)) {
|
|
116
|
+
const json = Reflect.get(value, "toJSON") as () => Record<string, unknown>;
|
|
117
|
+
return _deepToJson(json.call(value), seen);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A Date, a Carbon, anything with its own serialisation — leave it to the value's own
|
|
121
|
+
// contract rather than walking into its internals.
|
|
122
|
+
if (typeof Reflect.get(value, "toJSON") === "function") return value;
|
|
123
|
+
|
|
124
|
+
const out: Record<string, unknown> = {};
|
|
125
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
126
|
+
out[k] = _deepToJson(v, seen);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The fields a client may **write back**: `fillable`.
|
|
133
|
+
*
|
|
134
|
+
* `hidden` is not subtracted, because the two allow-lists answer different questions.
|
|
135
|
+
* `fillable`/`guarded` govern writes; `visible`/`hidden` govern display. A password is the
|
|
136
|
+
* case that makes the difference concrete: it is fillable because a user sets it, and hidden
|
|
137
|
+
* because the stored hash must never be shown. Subtracting made it unwritable too, which
|
|
138
|
+
* meant a bound password field silently did nothing.
|
|
139
|
+
*
|
|
140
|
+
* What keeps that safe is that a hidden field is never *sent* — see the synth's `dehydrate`
|
|
141
|
+
* and {@link _pendingSecrets}.
|
|
142
|
+
*
|
|
143
|
+
* `fillable` on its own is the wrong set. It is the mass-assignment allow-list — what a user
|
|
144
|
+
* may *write* — and that is not the same as what may be *shown*. The ORM's own example is the
|
|
145
|
+
* counter-example:
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* static fillable = ["name", "email", "password"];
|
|
149
|
+
* static hidden = ["password"];
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* A password is fillable and must never reach a browser. The subtraction lives here rather
|
|
153
|
+
* than in each model because the failure is silent: nothing goes wrong until someone reads
|
|
154
|
+
* the snapshot in dev tools.
|
|
155
|
+
*
|
|
156
|
+
* A model that declares no `fillable` accepts **no writes at all** — which is the ORM's own
|
|
157
|
+
* default, since it guards mass assignment until told otherwise. Such a model is still fully
|
|
158
|
+
* readable on the client; it simply cannot be edited there.
|
|
159
|
+
*
|
|
160
|
+
* @internal
|
|
161
|
+
*/
|
|
162
|
+
export function _writableFields(ModelClass: typeof BaseModel): readonly string[] {
|
|
163
|
+
const cached = _wireFieldCache.get(ModelClass);
|
|
164
|
+
if (cached) return cached;
|
|
165
|
+
|
|
166
|
+
const fields = Object.freeze([...(ModelClass.fillable ?? [])]);
|
|
167
|
+
|
|
168
|
+
_wireFieldCache.set(ModelClass, fields);
|
|
169
|
+
return fields;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether a value is an instance of a model registered with {@link registerFlowModel}.
|
|
174
|
+
*
|
|
175
|
+
* The same duck-typed check the synth's `match` uses, exported so `$set` can recognise a
|
|
176
|
+
* hydrated model without importing `BaseModel` (which would be circular).
|
|
177
|
+
*
|
|
178
|
+
* @internal
|
|
179
|
+
*/
|
|
180
|
+
export function _isRegisteredModel(value: unknown): boolean {
|
|
181
|
+
return (
|
|
182
|
+
typeof value === "object" &&
|
|
183
|
+
value !== null &&
|
|
184
|
+
typeof (value as Record<string, unknown>)["constructor"] === "function" &&
|
|
185
|
+
_modelClassToName.has((value as object).constructor as typeof BaseModel)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Hidden fields whose current value came from the client, per model instance.
|
|
191
|
+
*
|
|
192
|
+
* A hidden column is never sent — that is the point of `hidden`, and the stored password hash
|
|
193
|
+
* has no business in a page. But a hidden column can still be *written*: a user typing a new
|
|
194
|
+
* password needs that value to survive until they save, and dropping it would mean the field
|
|
195
|
+
* silently emptied itself on the next interaction.
|
|
196
|
+
*
|
|
197
|
+
* The distinction that makes this safe is **who produced the value**. Echoing back something
|
|
198
|
+
* the client just typed tells it nothing it does not already have. Echoing a value the server
|
|
199
|
+
* computed — a generated token, a rotated secret — would be a disclosure, which is why this
|
|
200
|
+
* tracks what {@link _applyWireValues} actually applied rather than asking `$dirty()`, which
|
|
201
|
+
* cannot tell the two apart.
|
|
202
|
+
*
|
|
203
|
+
* @internal
|
|
204
|
+
*/
|
|
205
|
+
const _pendingSecrets = new WeakMap<object, Set<string>>();
|
|
206
|
+
|
|
207
|
+
/** The hidden fields on `model` currently holding a client-supplied value. @internal */
|
|
208
|
+
export function _pendingSecretKeys(model: object): readonly string[] {
|
|
209
|
+
return [...(_pendingSecrets.get(model) ?? [])];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Mark hidden fields as client-supplied, so they survive the next round-trip. @internal */
|
|
213
|
+
export function _markPendingSecrets(model: object, keys: readonly string[]): void {
|
|
214
|
+
if (keys.length === 0) return;
|
|
215
|
+
const set = _pendingSecrets.get(model) ?? new Set<string>();
|
|
216
|
+
for (const k of keys) set.add(k);
|
|
217
|
+
_pendingSecrets.set(model, set);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Restore a model to the state it was in when we last dehydrated it.
|
|
222
|
+
*
|
|
223
|
+
* The values come from the snapshot, which is HMAC-signed over `data` and `memo` — so they
|
|
224
|
+
* are the server's own last output, not anything a browser could author. A change an action
|
|
225
|
+
* made and did not save is part of that state, and restoring it is what stops the field
|
|
226
|
+
* reverting the moment anything else happens.
|
|
227
|
+
*
|
|
228
|
+
* **Only the writable fields.** Everything else — `role`, `createdAt`, anything the server
|
|
229
|
+
* owns — comes from the row that was just fetched, which is current by definition. Carrying
|
|
230
|
+
* those forward from a snapshot would replace fresh values with older ones for no gain,
|
|
231
|
+
* since nothing in the round-trip could have changed them. The editable subset is the only
|
|
232
|
+
* part that holds state worth preserving.
|
|
233
|
+
*
|
|
234
|
+
* (Hidden columns never appear here anyway: `toJSON()` leaves them out, so there is nothing
|
|
235
|
+
* to restore. The set is the same one {@link _applyWireValues} enforces.)
|
|
236
|
+
*
|
|
237
|
+
* Only properties the instance already carries are set, so an `appends` entry or a computed
|
|
238
|
+
* accessor in `toJSON()` output cannot be written over the real one. `id` is skipped — the
|
|
239
|
+
* identity is `meta.id` and the row was fetched by it.
|
|
240
|
+
*
|
|
241
|
+
* @internal
|
|
242
|
+
*/
|
|
243
|
+
export function _restoreSnapshotValues(model: object, data: unknown): void {
|
|
244
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return;
|
|
245
|
+
|
|
246
|
+
const writable = new Set(_writableFields(model.constructor as typeof BaseModel));
|
|
247
|
+
|
|
248
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
249
|
+
if (key === "id" || key.startsWith("_")) continue;
|
|
250
|
+
if (!writable.has(key)) continue;
|
|
251
|
+
if (!Object.prototype.hasOwnProperty.call(model, key)) continue;
|
|
252
|
+
Reflect.set(model, key, value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply client-supplied values to a model, keeping only its wire fields.
|
|
258
|
+
*
|
|
259
|
+
* Filters rather than letting `fill()` reject: `fill()` throws `MassAssignmentError` off
|
|
260
|
+
* `fillable`, and this data comes from the browser, so a hostile payload would become a 500
|
|
261
|
+
* instead of being ignored. Everything that survives the filter is fillable by construction,
|
|
262
|
+
* so `fill()` runs its normal path — reactive accessors, casts and all.
|
|
263
|
+
*
|
|
264
|
+
* @internal
|
|
265
|
+
*/
|
|
266
|
+
export function _applyWireValues(model: object, data: unknown): void {
|
|
267
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return;
|
|
268
|
+
|
|
269
|
+
const allowed = new Set(_writableFields(model.constructor as typeof BaseModel));
|
|
270
|
+
const incoming: Record<string, unknown> = {};
|
|
271
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
272
|
+
if (allowed.has(key)) incoming[key] = value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (Object.keys(incoming).length === 0) return;
|
|
276
|
+
|
|
277
|
+
// `fill` is typed `UpdatePayload<this>`, which cannot be expressed for a model this
|
|
278
|
+
// function only knows as `BaseModel`. The values are already filtered to the class's own
|
|
279
|
+
// `fillable`, so the assertion is narrower than it looks.
|
|
280
|
+
(model as unknown as { fill(v: Record<string, unknown>): unknown }).fill(incoming);
|
|
281
|
+
|
|
282
|
+
// A hidden field the client just set has to travel back, or it empties itself on the next
|
|
283
|
+
// interaction. Recorded here, where we know the value came from the browser.
|
|
284
|
+
const hidden = new Set((model.constructor as typeof BaseModel).hidden ?? []);
|
|
285
|
+
_markPendingSecrets(
|
|
286
|
+
model,
|
|
287
|
+
Object.keys(incoming).filter((k) => hidden.has(k)),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
44
291
|
registerSynth({
|
|
45
292
|
key: "mdl",
|
|
46
293
|
|
|
47
|
-
match
|
|
48
|
-
// Lazy check: duck-type for BaseModel (avoids circular import of BaseModel class)
|
|
49
|
-
return (
|
|
50
|
-
typeof value === "object" &&
|
|
51
|
-
value !== null &&
|
|
52
|
-
typeof (value as Record<string, unknown>)["id"] !== "undefined" &&
|
|
53
|
-
typeof (value as Record<string, unknown>)["constructor"] === "function" &&
|
|
54
|
-
_modelClassToName.has((value as object).constructor as typeof BaseModel)
|
|
55
|
-
);
|
|
56
|
-
},
|
|
294
|
+
match: _isModel,
|
|
57
295
|
|
|
58
296
|
dehydrate(value: BaseModel, meta) {
|
|
59
297
|
// Use the stable registered name rather than constructor.name, which is
|
|
60
298
|
// mangled in minified production builds.
|
|
61
|
-
|
|
62
|
-
|
|
299
|
+
const ctor = (value as object).constructor as typeof BaseModel;
|
|
300
|
+
const key = _modelKey(ctor);
|
|
301
|
+
if (!key) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`[Flow] ${ctor.name} has no table name, so it cannot be sent across the wire. ` +
|
|
304
|
+
`Give it @table("…") — every model in app/models has one.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
meta["class"] = key;
|
|
308
|
+
|
|
309
|
+
// The id goes in `meta`, never in `data`. `$set` replaces a prop's data and never
|
|
310
|
+
// touches its meta, so keeping the key out of the editable payload is what makes
|
|
311
|
+
// "the client cannot change which record this is" true rather than merely intended.
|
|
312
|
+
meta["id"] = Reflect.get(value, "id");
|
|
313
|
+
|
|
314
|
+
// What the client is *shown* is the model's own serialisation surface — `toJSON()`, which
|
|
315
|
+
// already honours `visible` / `hidden` / `appends` and any per-instance overrides. Using
|
|
316
|
+
// the writable set here instead would have made `@locked` pointless: a display model
|
|
317
|
+
// declares no `fillable`, so it would have arrived as a bare id, and a prop that puts
|
|
318
|
+
// nothing on the client is what `@transient` is for.
|
|
319
|
+
const toJson = Reflect.get(value, "toJSON");
|
|
320
|
+
const out: Record<string, unknown> =
|
|
321
|
+
typeof toJson === "function"
|
|
322
|
+
? (_deepToJson(toJson.call(value)) as Record<string, unknown>)
|
|
323
|
+
: { id: Reflect.get(value, "id") };
|
|
324
|
+
|
|
325
|
+
// Hidden fields the client itself supplied ride along, and say so in `meta` so the next
|
|
326
|
+
// hydrate keeps treating them as pending rather than as a read of the row. Nothing the
|
|
327
|
+
// *server* put there is echoed — only what came back through `updates`.
|
|
328
|
+
const pending = _pendingSecretKeys(value);
|
|
329
|
+
if (pending.length > 0) {
|
|
330
|
+
for (const key of pending) out[key] = Reflect.get(value, key);
|
|
331
|
+
meta["p"] = pending;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return out;
|
|
63
335
|
},
|
|
64
336
|
|
|
65
337
|
async hydrate(data, meta) {
|
|
66
338
|
const cls = meta["class"] as string | undefined;
|
|
67
339
|
if (!cls) throw new Error("[Flow] ModelSynth: missing class in meta");
|
|
68
|
-
const Model =
|
|
340
|
+
const Model = _resolveModel(cls);
|
|
69
341
|
if (!Model) {
|
|
70
342
|
throw new Error(
|
|
71
|
-
`[Flow]
|
|
72
|
-
`
|
|
343
|
+
`[Flow] No model maps to "${cls}". Models live in app/models, where they are ` +
|
|
344
|
+
`discovered automatically — check the class is there and its @table matches.`,
|
|
73
345
|
);
|
|
74
346
|
}
|
|
75
|
-
|
|
76
|
-
|
|
347
|
+
|
|
348
|
+
// `meta.id` is the identity; `data` is only ever field values. A snapshot written before
|
|
349
|
+
// wire fields existed carried the id as `data`, so fall back to it — those stay valid
|
|
350
|
+
// for their remaining lifetime rather than throwing.
|
|
351
|
+
const id = meta["id"] !== undefined ? meta["id"] : data;
|
|
352
|
+
|
|
353
|
+
// Fetch first, overlay second. The fresh row supplies everything the client did not
|
|
354
|
+
// touch; the client's values win where it typed. `_original` is therefore the row as it
|
|
355
|
+
// is *now*, so `$dirty()` — and the UPDATE `save()` builds from it — covers exactly the
|
|
356
|
+
// fields that genuinely differ from the database.
|
|
357
|
+
const model = await (
|
|
358
|
+
Model as unknown as { findOrFail(id: unknown): Promise<BaseModel> }
|
|
359
|
+
).findOrFail(id);
|
|
360
|
+
|
|
361
|
+
// Fetch for identity — a live instance, its relations, and a 404 if the row is gone —
|
|
362
|
+
// then restore the state we last had. The snapshot is signed, so those values are ours,
|
|
363
|
+
// not the client's; an unsaved change an action made is part of the component's state and
|
|
364
|
+
// has to survive the round-trip or the field would revert the moment anything else
|
|
365
|
+
// happened.
|
|
366
|
+
//
|
|
367
|
+
// Client *intent* arrives separately, in the frame's `updates` map, and is applied after
|
|
368
|
+
// this through `_applyClientUpdate` — where `fillable` is enforced.
|
|
369
|
+
_restoreSnapshotValues(model, data);
|
|
370
|
+
|
|
371
|
+
// Keep the marks across the round-trip, so a pending password survives more than one
|
|
372
|
+
// interaction rather than only the next.
|
|
373
|
+
const pending = meta["p"];
|
|
374
|
+
if (Array.isArray(pending)) _markPendingSecrets(model, pending as string[]);
|
|
375
|
+
|
|
376
|
+
return model;
|
|
77
377
|
},
|
|
78
378
|
});
|