@zerotal/flow 1.8.1 → 1.10.0
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 +57 -0
- package/api-surface.md +17 -195
- package/package.json +6 -6
- package/src/Component.ts +13 -10
- package/src/Form.ts +5 -1
- package/src/browser/FlowBrowser.ts +3 -2
- package/src/browser/cdp.ts +2 -0
- package/src/client/confirm.ts +6 -6
- package/src/client/enhance.ts +205 -0
- package/src/client/enhanceSupport.ts +99 -0
- package/src/client/url.ts +2 -2
- package/src/compiler/helpers.ts +10 -2
- package/src/config.ts +5 -1
- package/src/decorators.ts +6 -4
- package/src/durable.ts +4 -0
- package/src/enhanceTag.ts +36 -0
- package/src/index.ts +1 -0
- package/src/provider/FlowProvider.ts +94 -0
- package/src/registry.ts +4 -0
- package/src/shared.ts +4 -0
- package/src/synths/ModelSynth.ts +77 -1
- package/src/synths/index.ts +4 -0
- package/src/types.ts +20 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +2 -0
package/src/synths/ModelSynth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BaseModel } from "@zerotal/orm";
|
|
2
2
|
import { modelsByName } from "@zerotal/orm";
|
|
3
|
+
import { isProdLike, deployEnv } from "@zerotal/core";
|
|
3
4
|
import { registerSynth } from "./index.ts";
|
|
4
5
|
|
|
5
6
|
// Models register themselves here when their class is imported.
|
|
@@ -169,6 +170,71 @@ export function _writableFields(ModelClass: typeof BaseModel): readonly string[]
|
|
|
169
170
|
return fields;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/** Model class → fields already reported, so a hundred-row list logs once. */
|
|
174
|
+
const _warnedUnfillable = new WeakMap<object, Set<string>>();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Say that a bound field was dropped, once per class per field.
|
|
178
|
+
*
|
|
179
|
+
* A `flow:model` on a column missing from `fillable` is almost always a typo or a
|
|
180
|
+
* forgotten entry, and the symptom is a form that appears to work and saves
|
|
181
|
+
* nothing. The alternative — throwing — is not available: the same path receives
|
|
182
|
+
* whatever a browser chooses to send, and a hostile payload naming an unknown key
|
|
183
|
+
* must not be able to 500 the page. So: keep dropping, and say so where the person
|
|
184
|
+
* who can fix it will see it.
|
|
185
|
+
*
|
|
186
|
+
* Keyed per class *and* per field, so a second unfillable field on the same model
|
|
187
|
+
* is still reported. Silent in production, where nobody is reading the log for
|
|
188
|
+
* this and the payload may well be hostile.
|
|
189
|
+
*/
|
|
190
|
+
function _warnUnfillableBinding(ModelClass: typeof BaseModel, dropped: string[]): void {
|
|
191
|
+
if (_isProductionRuntime()) return;
|
|
192
|
+
|
|
193
|
+
let seen = _warnedUnfillable.get(ModelClass);
|
|
194
|
+
if (!seen) {
|
|
195
|
+
seen = new Set<string>();
|
|
196
|
+
_warnedUnfillable.set(ModelClass, seen);
|
|
197
|
+
}
|
|
198
|
+
const fresh = dropped.filter((field) => !seen.has(field));
|
|
199
|
+
if (fresh.length === 0) return;
|
|
200
|
+
for (const field of fresh) seen.add(field);
|
|
201
|
+
|
|
202
|
+
const name = ModelClass.name || "the model";
|
|
203
|
+
const fillable = [...(ModelClass.fillable ?? [])];
|
|
204
|
+
const listed = fillable.map((f) => `"${f}"`).join(", ") || "empty";
|
|
205
|
+
console.warn(
|
|
206
|
+
`[Zerotal Flow] ${name} received ${fresh.map((f) => `\`${f}\``).join(", ")} from the ` +
|
|
207
|
+
`browser and dropped ${fresh.length === 1 ? "it" : "them"}: not in \`fillable\`, so ` +
|
|
208
|
+
`nothing was written and nothing failed.\n` +
|
|
209
|
+
` fillable is [${listed}].\n` +
|
|
210
|
+
` If the binding is intended, add the field: static fillable = […, "${fresh[0]}"];\n` +
|
|
211
|
+
` If it is not, remove the flow:model — mass assignment is the reason this list exists.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Forget what has been warned about, for one model class. Tests.
|
|
217
|
+
*
|
|
218
|
+
* A `WeakMap` has no `clear()`, and it does not need one here: every key a test
|
|
219
|
+
* seeds is a class the test itself owns, and dropping that one entry is the whole
|
|
220
|
+
* reset. Classes from other tests are unreachable by then.
|
|
221
|
+
*
|
|
222
|
+
* @internal
|
|
223
|
+
*/
|
|
224
|
+
export function _resetUnfillableWarnings(ModelClass: object): void {
|
|
225
|
+
_warnedUnfillable.delete(ModelClass);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether this process is a production deployment.
|
|
230
|
+
*
|
|
231
|
+
* `deployEnv()`, not `APP_ENV` — the latter holds the runtime mode once the app
|
|
232
|
+
* has booted, so reading it answered "no" on every production server.
|
|
233
|
+
*/
|
|
234
|
+
function _isProductionRuntime(): boolean {
|
|
235
|
+
return isProdLike(deployEnv()) || Bun.env["NODE_ENV"] === "production";
|
|
236
|
+
}
|
|
237
|
+
|
|
172
238
|
/**
|
|
173
239
|
* Whether a value is an instance of a model registered with {@link registerFlowModel}.
|
|
174
240
|
*
|
|
@@ -266,12 +332,22 @@ export function _restoreSnapshotValues(model: object, data: unknown): void {
|
|
|
266
332
|
export function _applyWireValues(model: object, data: unknown): void {
|
|
267
333
|
if (data === null || typeof data !== "object" || Array.isArray(data)) return;
|
|
268
334
|
|
|
269
|
-
const
|
|
335
|
+
const ModelClass = model.constructor as typeof BaseModel;
|
|
336
|
+
const allowed = new Set(_writableFields(ModelClass));
|
|
270
337
|
const incoming: Record<string, unknown> = {};
|
|
338
|
+
const dropped: string[] = [];
|
|
271
339
|
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
272
340
|
if (allowed.has(key)) incoming[key] = value;
|
|
341
|
+
else dropped.push(key);
|
|
273
342
|
}
|
|
274
343
|
|
|
344
|
+
// Dropping is right — a hostile payload must not become a 500, and it must not
|
|
345
|
+
// write a column nobody allowed. What was wrong is that it happened in silence:
|
|
346
|
+
// bind a field that is not `fillable` and there is no write, no error and no
|
|
347
|
+
// clue, which is the failure shape this project's own severity key calls the
|
|
348
|
+
// worst kind. Development only, once per class and field.
|
|
349
|
+
if (dropped.length > 0) _warnUnfillableBinding(ModelClass, dropped);
|
|
350
|
+
|
|
275
351
|
if (Object.keys(incoming).length === 0) return;
|
|
276
352
|
|
|
277
353
|
// `fill` is typed `UpdatePayload<this>`, which cannot be expressed for a model this
|
package/src/synths/index.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* synth re-fetches by id).
|
|
23
23
|
*
|
|
24
24
|
* @typeParam T - The live value type this synth handles.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
25
27
|
*/
|
|
26
28
|
export interface Synth<T = unknown> {
|
|
27
29
|
/** Short tag stored as `meta.s` in the snapshot, e.g. `'cbn'`, `'mdl'`. */
|
|
@@ -54,6 +56,8 @@ const _synths: Synth[] = [];
|
|
|
54
56
|
* hydrate: (data, meta) => new Money(data as number, meta.cur as string),
|
|
55
57
|
* });
|
|
56
58
|
* ```
|
|
59
|
+
*
|
|
60
|
+
* @internal
|
|
57
61
|
*/
|
|
58
62
|
export function registerSynth(synth: Synth): void {
|
|
59
63
|
_synths.unshift(synth); // user-registered synths take priority
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type SnapshotTuple = [data: unknown, meta: Record<string, unknown>];
|
|
|
13
13
|
/**
|
|
14
14
|
* The "data" map inside a snapshot: `propertyName → [value, meta]`.
|
|
15
15
|
* @category Snapshot
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
16
18
|
*/
|
|
17
19
|
export type SnapshotData = Record<string, SnapshotTuple>;
|
|
18
20
|
|
|
@@ -20,6 +22,8 @@ export type SnapshotData = Record<string, SnapshotTuple>;
|
|
|
20
22
|
* Metadata for a child Component component embedded inside a parent's render output.
|
|
21
23
|
* Stored in memo.children (excluded from HMAC signing).
|
|
22
24
|
* @category Snapshot
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
23
27
|
*/
|
|
24
28
|
export interface ChildMemo {
|
|
25
29
|
/** The JSX `key` prop that identifies this slot in the parent's template. */
|
|
@@ -33,6 +37,8 @@ export interface ChildMemo {
|
|
|
33
37
|
/**
|
|
34
38
|
* Component identity / routing metadata embedded in the snapshot.
|
|
35
39
|
* @category Snapshot
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
36
42
|
*/
|
|
37
43
|
export interface SnapshotMemo {
|
|
38
44
|
id: string;
|
|
@@ -92,6 +98,8 @@ export interface Snapshot {
|
|
|
92
98
|
/**
|
|
93
99
|
* Outbound WS frame sent by the client to the server.
|
|
94
100
|
* @category Frames
|
|
101
|
+
*
|
|
102
|
+
* @internal
|
|
95
103
|
*/
|
|
96
104
|
export interface CallFrame {
|
|
97
105
|
type: "call";
|
|
@@ -112,6 +120,8 @@ export interface CallFrame {
|
|
|
112
120
|
* legacy `snapshot` field remains as a full fallback. `html` is omitted when the
|
|
113
121
|
* re-render is byte-identical to the last patch on this connection (HTML suppression).
|
|
114
122
|
* @category Frames
|
|
123
|
+
*
|
|
124
|
+
* @internal
|
|
115
125
|
*/
|
|
116
126
|
export interface PatchFrame {
|
|
117
127
|
type: "patch";
|
|
@@ -215,6 +225,8 @@ export interface FlashMessage {
|
|
|
215
225
|
/**
|
|
216
226
|
* Flash notification frame.
|
|
217
227
|
* @category Frames
|
|
228
|
+
*
|
|
229
|
+
* @internal
|
|
218
230
|
*/
|
|
219
231
|
export interface FlashFrame extends FlashMessage {
|
|
220
232
|
type: "flash";
|
|
@@ -223,6 +235,8 @@ export interface FlashFrame extends FlashMessage {
|
|
|
223
235
|
/**
|
|
224
236
|
* Programmatic redirect frame.
|
|
225
237
|
* @category Frames
|
|
238
|
+
*
|
|
239
|
+
* @internal
|
|
226
240
|
*/
|
|
227
241
|
export interface RedirectFrame {
|
|
228
242
|
type: "redirect";
|
|
@@ -233,6 +247,8 @@ export interface RedirectFrame {
|
|
|
233
247
|
/**
|
|
234
248
|
* Error frame (action threw, onError ran).
|
|
235
249
|
* @category Frames
|
|
250
|
+
*
|
|
251
|
+
* @internal
|
|
236
252
|
*/
|
|
237
253
|
export interface ErrorFrame {
|
|
238
254
|
type: "error";
|
|
@@ -261,6 +277,8 @@ export interface StreamFrame {
|
|
|
261
277
|
/**
|
|
262
278
|
* Cross-component event frame.
|
|
263
279
|
* @category Frames
|
|
280
|
+
*
|
|
281
|
+
* @internal
|
|
264
282
|
*/
|
|
265
283
|
export interface EventFrame {
|
|
266
284
|
type: "event";
|
|
@@ -271,6 +289,8 @@ export interface EventFrame {
|
|
|
271
289
|
/**
|
|
272
290
|
* File download frame.
|
|
273
291
|
* @category Frames
|
|
292
|
+
*
|
|
293
|
+
* @internal
|
|
274
294
|
*/
|
|
275
295
|
export interface DownloadFrame {
|
|
276
296
|
type: "download";
|
package/src/urlSafety.ts
CHANGED
|
Binary file
|
package/src/utils.ts
CHANGED