@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.
- package/CHANGELOG.md +176 -0
- package/README.md +1 -1
- package/api-surface.md +10 -6
- package/package.json +6 -5
- package/src/Component.ts +128 -50
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +353 -83
- package/src/client/framePanes.ts +229 -0
- package/src/client/headless.ts +7 -1
- package/src/client/timeline.ts +12 -0
- package/src/components.ts +13 -2
- package/src/decorators.ts +104 -14
- package/src/dehydrate.ts +51 -2
- package/src/durable.ts +5 -2
- package/src/events.ts +3 -3
- package/src/index.ts +1 -0
- package/src/jsx-runtime.ts +13 -0
- package/src/observability.ts +28 -1
- 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/src/observability.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
});
|