@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 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 *class*, so
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 *emitted* directive, not the prop:
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()`, `this.title()`, `this.client()`, `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`.
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: (_options?: CurrentUrlOptions) => string
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: (_options?: CurrentUrlOptions) => Promise<void>
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: LooseEventName) => (fn: unknown, context: ClassMethodDecoratorContext) => void
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.2",
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.2",
51
- "@zerotal/core": "1.7.2",
52
- "@zerotal/orm": "1.7.2",
53
- "@zerotal/validator": "1.7.2",
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.2",
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 { CurrentUrlOptions } from "./client/url.ts";
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
- * Gets the current title value.
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
- * @example
1059
- * this.title(`Search: ${this.query}`);
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
- title(value: string): void;
1064
-
1065
- // Implementation signature (hidden from the public API)
1066
- title(value?: string): void | string {
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._titleValue = value;
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 — client-only. Use it in a
1290
- * JSX expression: `href={this.currentUrl({ query: this.q })}`, `class={this.currentUrl() === "/" ? "on" : ""}`,
1291
- * or `{this.currentUrl(...)}`. The compiler rewrites `this.currentUrl` → `$flow.currentUrl`
1292
- * so it runs on the client. It never runs on the server — hitting this throw means the call
1293
- * reached server code (e.g. a page the compiler couldn't statically compile, or a server action).
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(_options?: CurrentUrlOptions): string {
1299
- return _clientOnlyUrlHelper("currentUrl");
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 SPA-navigate to it — client-only. Use it in a
1304
- * client handler: `onClick={() => this.navigateCurrent({ query: { page: 2 } })}`. The compiler
1305
- * rewrites it to `$flow.navigateCurrent`. To navigate from server code, return a `redirect()`.
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(_options?: CurrentUrlOptions): Promise<void> {
1311
- return _clientOnlyUrlHelper("navigateCurrent");
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._titleValue,
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.
@@ -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
- const _startVT = (document as unknown as { startViewTransition?: (cb: () => void) => unknown })
2904
- .startViewTransition;
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) _startVT.call(document, _swap);
2908
- else _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
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
- await _store.set(key, snapshot, cfg.ttlMs);
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
- /** Sets the HTML <title> for this page. */
111
- title?: string;
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
- const titleTag = PageClass.title ? `<title>${PageClass.title}</title>` : "";
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 { _modelRegistry, _modelClassToName } from "./ModelSynth.ts";
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
- if (typeof first !== "object" || first === null) return false;
26
- // Key off the constructor, not constructor.name, so the match survives
27
- // minification (same hardening as ModelSynth).
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
- meta["class"] = _modelClassToName.get(ctor)!;
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 = _modelRegistry.get(cls);
48
+ const Model = _resolveModel(cls);
47
49
  if (!Model) {
48
50
  throw new Error(
49
- `[Flow] Model "${cls}" is not registered. ` +
50
- `Add it to your Component's static models property.`,
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[];
@@ -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
- * Register an ORM model class under a stable name so the model synth can send it
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
- * Registering by an explicit name (rather than `constructor.name`) keeps snapshots
21
- * valid under minification, where the class name may be mangled. You normally don't
22
- * call this directly — declare models in a component's `static models` map and the
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
- * @param name - Stable, developer-assigned name recorded in the snapshot's `meta.class`.
26
- * @param ModelClass - The `BaseModel` subclass to associate with `name`.
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
- * @example
29
- * ```tsx
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
- * // Or register manually (e.g. outside a page's static models):
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(value): value is BaseModel {
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
- meta["class"] = _modelClassToName.get((value as object).constructor as typeof BaseModel)!;
62
- return (value as unknown as Record<string, unknown>)["id"];
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 = _modelRegistry.get(cls);
340
+ const Model = _resolveModel(cls);
69
341
  if (!Model) {
70
342
  throw new Error(
71
- `[Flow] Model "${cls}" is not registered. ` +
72
- `Add it to your Component's static models property.`,
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
- // BaseModel.findOrFail is a static method
76
- return (Model as unknown as { findOrFail(id: unknown): Promise<BaseModel> }).findOrFail(data);
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
  });