@zerotal/flow 1.7.0 → 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.
@@ -44,8 +44,15 @@ interface DevtoolsSink {
44
44
  meta?: string[];
45
45
  warn?: string;
46
46
  order?: number;
47
+ hidden?: boolean;
47
48
  }): void;
48
49
  record(ctx: object, channel: string, entry: Record<string, unknown>): void;
50
+ /**
51
+ * Optional because it postdates the sink: a project can pin a devtools older
52
+ * than this Flow, and an action that goes untraced is a worse panel, not a
53
+ * crashed one.
54
+ */
55
+ finalise?(ctx: object, meta: { startMs: number; durationMs: number; method?: string }): void;
49
56
  }
50
57
 
51
58
  // Framework/asset noise the panel shouldn't chart as realtime traffic.
@@ -134,6 +141,12 @@ export function installFlowObservability(app: Application): () => void {
134
141
 
135
142
  const trace = _observer<DevtoolsSink>(app, "devtools.trace");
136
143
  if (trace) {
144
+ // Recorded on the trace, but with no tab of its own: Flow's client panel is
145
+ // the better home for it. A time-travel frame already names the component,
146
+ // the action and the fields it changed, so a separate tab could only repeat
147
+ // that beside a duration the trace header already shows — and the frames and
148
+ // the actions are the same clicks seen from the two ends. The panel reads
149
+ // these entries off the trace and prints them on the frame they belong to.
137
150
  trace.channel({
138
151
  id: "flow",
139
152
  label: "Flow",
@@ -142,12 +155,18 @@ export function installFlowObservability(app: Application): () => void {
142
155
  meta: ["durationMs", "ip"],
143
156
  warn: "failed",
144
157
  order: 20,
158
+ hidden: true,
145
159
  });
146
160
 
147
161
  unsubs.push(
148
162
  FrameworkEvents.on(FlowActionHandled, (e) => {
149
163
  // An action round-trips over the WebSocket against its own HttpContext,
150
- // so it buffers against that context and lands on that action's trace.
164
+ // so it buffers against that context — and then has to finalise that
165
+ // context itself. Devtools builds traces from core's request lifecycle,
166
+ // and a WebSocket action fires none of it, so for as long as this call
167
+ // was missing every action buffered its evidence and dropped it: the Flow
168
+ // tab reported "no flow activity" for apps whose every interaction is a
169
+ // Flow action, and the queries those actions ran showed up nowhere.
151
170
  if (!e.ctx) return;
152
171
  if (IGNORE_PREFIXES.some((p) => _rawPath(e.ctx as object).startsWith(p))) return;
153
172
  trace.record(e.ctx as object, "flow", {
@@ -157,6 +176,14 @@ export function installFlowObservability(app: Application): () => void {
157
176
  ip: e.ip,
158
177
  ...(e.ok ? {} : { failed: true }),
159
178
  });
179
+ // The synthetic request behind an action is a GET of the page the action
180
+ // ran on, so labelling it `FLOW` is what keeps it from reading as a second
181
+ // page load in the request list.
182
+ trace.finalise?.(e.ctx as object, {
183
+ startMs: Date.now() - e.durationMs,
184
+ durationMs: e.durationMs,
185
+ method: "FLOW",
186
+ });
160
187
  }),
161
188
  );
162
189
  }
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
  });