@zerotal/flow 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/shared.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ── @shared support (server side) ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `@shared` props are convergent, server-authoritative state bound to a broadcast
|
|
4
|
+
// channel. A per-channel room store holds the canonical value; before every action the
|
|
5
|
+
// prop is refilled from the store (read-latest), and after an action that changed it the
|
|
6
|
+
// new value is written back and broadcast to the channel so other subscribers converge.
|
|
7
|
+
//
|
|
8
|
+
// Broadcasting is an OPTIONAL peer: flow does not depend on it. We resolve `Broadcast`
|
|
9
|
+
// through a variable-specifier dynamic import (not type-checked, so no hard dependency)
|
|
10
|
+
// and no-op gracefully when it isn't installed — the store still works in-process, so a
|
|
11
|
+
// single-tab app converges within its own round-trips even without broadcasting.
|
|
12
|
+
|
|
13
|
+
import type { Component } from "./Component.ts";
|
|
14
|
+
import { getSharedProps, resolveSharedChannel } from "./decorators.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The wire event name the server broadcasts (and the client subscribes to) when
|
|
18
|
+
* one or more `@shared` props on a channel change, prompting subscribers to
|
|
19
|
+
* re-read the room store and converge.
|
|
20
|
+
*/
|
|
21
|
+
export const SHARED_EVENT = "flow:shared";
|
|
22
|
+
|
|
23
|
+
// ── Room store ────────────────────────────────────────────────────────────────
|
|
24
|
+
// Keyed by `channel::prop`. The default is an in-process Map (single-process apps and
|
|
25
|
+
// the showcase). Multi-instance deployments swap in a Redis-backed store via
|
|
26
|
+
// `setSharedStore()` — the same convergence logic then fans out across processes.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pluggable backing store for `@shared` room state — the canonical, per-channel
|
|
30
|
+
* value that all subscribers converge on.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* Keys are of the form `channel::prop`. Stored values are already deep-cloned by
|
|
34
|
+
* the caller, so the store may hold references without aliasing component state.
|
|
35
|
+
* The default is an in-process {@link MemorySharedStore}; swap a distributed
|
|
36
|
+
* implementation (e.g. Redis-backed) via {@link setSharedStore} to converge
|
|
37
|
+
* across processes. Implement all three methods synchronously.
|
|
38
|
+
*/
|
|
39
|
+
export interface SharedStore {
|
|
40
|
+
/** Read the current value for a `channel::prop` key, or `undefined` if unset. */
|
|
41
|
+
get(key: string): unknown | undefined;
|
|
42
|
+
/** Write the canonical value for a `channel::prop` key. */
|
|
43
|
+
set(key: string, value: unknown): void;
|
|
44
|
+
/** Whether a value has been stored for a `channel::prop` key. */
|
|
45
|
+
has(key: string): boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class MemorySharedStore implements SharedStore {
|
|
49
|
+
private _map = new Map<string, unknown>();
|
|
50
|
+
get(key: string): unknown | undefined {
|
|
51
|
+
return this._map.get(key);
|
|
52
|
+
}
|
|
53
|
+
set(key: string, value: unknown): void {
|
|
54
|
+
this._map.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
has(key: string): boolean {
|
|
57
|
+
return this._map.has(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let _store: SharedStore = new MemorySharedStore();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Replace the process-wide backing store for `@shared` room state. Call this once
|
|
65
|
+
* at boot to move convergence off the default in-process map onto a distributed
|
|
66
|
+
* backend so it fans out across server instances.
|
|
67
|
+
*
|
|
68
|
+
* @param store - The store implementation to install.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* // boot: converge @shared state across all app instances via Redis.
|
|
73
|
+
* setSharedStore(new RedisSharedStore(redis));
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function setSharedStore(store: SharedStore): void {
|
|
77
|
+
_store = store;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the active `@shared` room store (the in-process default, or whatever was
|
|
82
|
+
* installed via {@link setSharedStore}). Rarely needed in app code — `@shared`
|
|
83
|
+
* props read/write it automatically — but useful for tests or admin tooling that
|
|
84
|
+
* inspects room state directly.
|
|
85
|
+
*
|
|
86
|
+
* @returns The current {@link SharedStore}.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```tsx
|
|
90
|
+
* // A collaborative counter: every subscriber to `room.lobby` converges on `count`.
|
|
91
|
+
* class Counter extends Component {
|
|
92
|
+
* @shared("room.lobby") count = 0;
|
|
93
|
+
* @expose increment() { this.count++; } // written back + broadcast on commit
|
|
94
|
+
* }
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function getSharedStore(): SharedStore {
|
|
98
|
+
return _store;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal Test hook — clear the default in-memory store between tests. */
|
|
102
|
+
export function _resetSharedStore(): void {
|
|
103
|
+
_store = new MemorySharedStore();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _key(channel: string, prop: string): string {
|
|
107
|
+
return `${channel}::${prop}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Deep-clone a value so the store and the component never alias the same reference. */
|
|
111
|
+
function _clone<T>(value: T): T {
|
|
112
|
+
try {
|
|
113
|
+
return structuredClone(value);
|
|
114
|
+
} catch {
|
|
115
|
+
// Functions / class instances with methods aren't structured-cloneable — fall back to
|
|
116
|
+
// a JSON round-trip (the same shape the delta encoder already assumes for @shared props).
|
|
117
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Broadcasting (optional peer) ───────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface BroadcastBuilder {
|
|
124
|
+
as(event: string): BroadcastBuilder;
|
|
125
|
+
with(data: unknown): BroadcastBuilder;
|
|
126
|
+
toOthers(): BroadcastBuilder;
|
|
127
|
+
send(): void;
|
|
128
|
+
}
|
|
129
|
+
interface BroadcastFacade {
|
|
130
|
+
presence(channel: string): BroadcastBuilder;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let _broadcast: BroadcastFacade | null | undefined;
|
|
134
|
+
|
|
135
|
+
async function _resolveBroadcast(): Promise<BroadcastFacade | null> {
|
|
136
|
+
if (_broadcast !== undefined) return _broadcast;
|
|
137
|
+
try {
|
|
138
|
+
const spec = "@zerotal/broadcasting";
|
|
139
|
+
const mod = (await import(spec)) as { Broadcast?: BroadcastFacade };
|
|
140
|
+
_broadcast = mod.Broadcast ?? null;
|
|
141
|
+
} catch {
|
|
142
|
+
_broadcast = null; // broadcasting not installed → in-process convergence only
|
|
143
|
+
}
|
|
144
|
+
return _broadcast;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Populate / snapshot / commit ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Refill every `@shared` prop on `page` from the room store (server-authoritative
|
|
151
|
+
* read-latest). If a channel has no stored value yet, seed the store with the prop's
|
|
152
|
+
* current default so the first writer converges against a known baseline. Called before
|
|
153
|
+
* every action and on the initial GET; a no-op when the component has no `@shared` props.
|
|
154
|
+
*/
|
|
155
|
+
export function populateShared(page: Component): void {
|
|
156
|
+
const props = getSharedProps(page);
|
|
157
|
+
if (props.size === 0) return;
|
|
158
|
+
const store = _store;
|
|
159
|
+
const bag = page as unknown as Record<string, unknown>;
|
|
160
|
+
for (const [prop, channel] of props) {
|
|
161
|
+
const key = _key(resolveSharedChannel(channel, page), prop);
|
|
162
|
+
if (store.has(key)) {
|
|
163
|
+
bag[prop] = _clone(store.get(key));
|
|
164
|
+
} else {
|
|
165
|
+
store.set(key, _clone(bag[prop])); // seed with the current default
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Capture the current `@shared` prop values (as stable JSON) so a later
|
|
172
|
+
* {@link commitShared} can detect which ones an action changed.
|
|
173
|
+
*/
|
|
174
|
+
export function snapshotSharedValues(page: Component): Map<string, string> {
|
|
175
|
+
const props = getSharedProps(page);
|
|
176
|
+
const before = new Map<string, string>();
|
|
177
|
+
if (props.size === 0) return before;
|
|
178
|
+
const bag = page as unknown as Record<string, unknown>;
|
|
179
|
+
for (const prop of props.keys()) before.set(prop, JSON.stringify(bag[prop]));
|
|
180
|
+
return before;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Write back every `@shared` prop whose value changed since `before`, then broadcast the
|
|
185
|
+
* change to its channel so other subscribers re-read and converge. Returns the names of
|
|
186
|
+
* the props that changed. No broadcast is sent when broadcasting isn't installed (the
|
|
187
|
+
* store is still updated, so same-process round-trips converge).
|
|
188
|
+
*/
|
|
189
|
+
export async function commitShared(
|
|
190
|
+
page: Component,
|
|
191
|
+
before: Map<string, string>,
|
|
192
|
+
): Promise<string[]> {
|
|
193
|
+
const props = getSharedProps(page);
|
|
194
|
+
if (props.size === 0) return [];
|
|
195
|
+
const store = _store;
|
|
196
|
+
const bag = page as unknown as Record<string, unknown>;
|
|
197
|
+
|
|
198
|
+
// Group changed props by channel so one channel broadcasts a single event.
|
|
199
|
+
const changedByChannel = new Map<string, string[]>();
|
|
200
|
+
const changed: string[] = [];
|
|
201
|
+
for (const [prop, channel] of props) {
|
|
202
|
+
const now = JSON.stringify(bag[prop]);
|
|
203
|
+
if (now === before.get(prop)) continue;
|
|
204
|
+
const name = resolveSharedChannel(channel, page);
|
|
205
|
+
store.set(_key(name, prop), _clone(bag[prop]));
|
|
206
|
+
changed.push(prop);
|
|
207
|
+
(changedByChannel.get(name) ?? changedByChannel.set(name, []).get(name)!).push(prop);
|
|
208
|
+
}
|
|
209
|
+
if (changed.length === 0) return [];
|
|
210
|
+
|
|
211
|
+
const Broadcast = await _resolveBroadcast();
|
|
212
|
+
if (Broadcast) {
|
|
213
|
+
for (const [channel, propsForChannel] of changedByChannel) {
|
|
214
|
+
try {
|
|
215
|
+
Broadcast.presence(channel)
|
|
216
|
+
.as(SHARED_EVENT)
|
|
217
|
+
.with({ props: propsForChannel })
|
|
218
|
+
.toOthers()
|
|
219
|
+
.send();
|
|
220
|
+
} catch {
|
|
221
|
+
/* broadcasting misconfigured — the store is still authoritative for new GETs */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return changed;
|
|
226
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// ── Global client store — a single Alpine store named "flow" ────────────────────
|
|
2
|
+
//
|
|
3
|
+
// App-wide, client-only UI state the SERVER never needs to see: theme, sidebar open/closed,
|
|
4
|
+
// a command-palette's visibility, an unsaved-changes flag. It IS an Alpine store — Alpine
|
|
5
|
+
// already gives us a global, reactive, dependency-tracked store, so we don't reinvent it.
|
|
6
|
+
// Everything lives under one store, `flow`, and `$flow.$store` returns it:
|
|
7
|
+
//
|
|
8
|
+
// <button onClick={() => ($flow.store.ui.dark = !$flow.store.ui.dark)}>Toggle</button>
|
|
9
|
+
// <div class={$flow.store.ui.dark ? "dark" : ""}>…</div>
|
|
10
|
+
// <span>{$flow.store.cart.count} items</span>
|
|
11
|
+
//
|
|
12
|
+
// Reads compile to Alpine bindings (x-text / :class / x-show) and re-render with no round-trip;
|
|
13
|
+
// writes mutate the reactive store directly. Declare the shape with defineStore().
|
|
14
|
+
//
|
|
15
|
+
// Division of labour:
|
|
16
|
+
// • $flow.store — live cross-component UI state the server never sees.
|
|
17
|
+
// • @session — a preference that must survive a refresh (persisted server-side).
|
|
18
|
+
// • @expose — state one component owns and the server may reconcile.
|
|
19
|
+
//
|
|
20
|
+
// Dependency-free (no @zerotal/core import) so it's safe in the browser runtime bundle.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shape of the global client store (`$flow.store`). Declare your namespaces by augmenting
|
|
24
|
+
* this interface, which makes every `$flow.store.*` access fully typed:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* // resources/js/env.d.ts — augment the /store subpath, where the interface is declared.
|
|
28
|
+
* declare module "@zerotal/flow/store" {
|
|
29
|
+
* interface FlowStore {
|
|
30
|
+
* ui: { dark: boolean; sidebar: boolean };
|
|
31
|
+
* cart: { count: number };
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
37
|
+
export interface FlowStore {}
|
|
38
|
+
|
|
39
|
+
/** The one Alpine store name that backs `$flow.store`. */
|
|
40
|
+
const STORE_NAME = "flow";
|
|
41
|
+
|
|
42
|
+
/** The slice of Alpine we use (register/read a named store). */
|
|
43
|
+
interface AlpineStores {
|
|
44
|
+
store(name: string): Record<string, unknown> | undefined;
|
|
45
|
+
store(name: string, value: Record<string, unknown>): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type GlobalWithAlpine = typeof globalThis & {
|
|
49
|
+
Alpine?: AlpineStores;
|
|
50
|
+
__flowStoreInit?: Array<Record<string, unknown>>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function _g(): GlobalWithAlpine {
|
|
54
|
+
return globalThis as GlobalWithAlpine;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Recursively fill defaults into `target` WITHOUT overwriting values already present. */
|
|
58
|
+
function _mergeDefaults(target: Record<string, unknown>, src: Record<string, unknown>): void {
|
|
59
|
+
for (const [k, v] of Object.entries(src)) {
|
|
60
|
+
const cur = target[k];
|
|
61
|
+
if (
|
|
62
|
+
v &&
|
|
63
|
+
typeof v === "object" &&
|
|
64
|
+
!Array.isArray(v) &&
|
|
65
|
+
cur &&
|
|
66
|
+
typeof cur === "object" &&
|
|
67
|
+
!Array.isArray(cur)
|
|
68
|
+
) {
|
|
69
|
+
_mergeDefaults(cur as Record<string, unknown>, v as Record<string, unknown>);
|
|
70
|
+
} else if (!(k in target)) {
|
|
71
|
+
target[k] = v;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Merge `initial` (defaults only) into the reactive "flow" Alpine store, registering it if new. */
|
|
77
|
+
function _seed(A: AlpineStores, initial: Record<string, unknown>): void {
|
|
78
|
+
if (!A.store(STORE_NAME)) A.store(STORE_NAME, {});
|
|
79
|
+
_mergeDefaults(A.store(STORE_NAME)!, initial); // mutating the reactive store keeps bindings live
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Declare (or extend) the client store's initial shape — call once at app start:
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* // resources/js/app.ts — import from the browser-safe /store subpath, not the package barrel.
|
|
87
|
+
* import { defineStore } from "@zerotal/flow/store";
|
|
88
|
+
* defineStore({ ui: { dark: false, sidebar: true }, cart: { count: 0 } });
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* Existing values are preserved (defaults only). Safe to call before OR after the Flow runtime
|
|
92
|
+
* boots — a call made before Alpine exists is queued and applied when the store is registered.
|
|
93
|
+
*/
|
|
94
|
+
export function defineStore(initial: Record<string, unknown>): void {
|
|
95
|
+
const g = _g();
|
|
96
|
+
if (g.Alpine?.store) _seed(g.Alpine, initial);
|
|
97
|
+
else (g.__flowStoreInit ??= []).push(initial);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @internal Register the "flow" Alpine store and apply any defineStore() calls queued before
|
|
102
|
+
* Alpine existed. Called once by the client entry, before `Alpine.start()`. Idempotent.
|
|
103
|
+
*/
|
|
104
|
+
export function initClientStore(alpine: AlpineStores): Record<string, unknown> {
|
|
105
|
+
if (!alpine.store(STORE_NAME)) alpine.store(STORE_NAME, {});
|
|
106
|
+
const g = _g();
|
|
107
|
+
for (const partial of g.__flowStoreInit ?? []) _seed(alpine, partial);
|
|
108
|
+
g.__flowStoreInit = [];
|
|
109
|
+
return alpine.store(STORE_NAME)!;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @internal The reactive "flow" store object (Alpine-owned), or `{}` before Alpine is ready. */
|
|
113
|
+
export function clientStore(): Record<string, unknown> {
|
|
114
|
+
const A = _g().Alpine;
|
|
115
|
+
return (A?.store && A.store(STORE_NAME)) || {};
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Synth (key "cbi") for CarbonInterval durations: serializes to an ISO 8601
|
|
2
|
+
// duration string (e.g. "P1DT2H30M") and hydrates back to a CarbonInterval.
|
|
3
|
+
// Registered as a side effect of importing this module.
|
|
4
|
+
import { CarbonInterval } from "@zerotal/core/carbon";
|
|
5
|
+
import { registerSynth } from "./index.ts";
|
|
6
|
+
|
|
7
|
+
registerSynth({
|
|
8
|
+
key: "cbi",
|
|
9
|
+
|
|
10
|
+
match(value): value is CarbonInterval {
|
|
11
|
+
return value instanceof CarbonInterval;
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
dehydrate(value: CarbonInterval, _meta) {
|
|
15
|
+
// Serialise as ISO 8601 duration string: "P1DT2H30M"
|
|
16
|
+
return value.toISO();
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
hydrate(data, _meta) {
|
|
20
|
+
return CarbonInterval.fromISO(data as string);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Synth (key "cbn") for Carbon datetimes: serializes to an ISO 8601 string and
|
|
2
|
+
// hydrates back to a Carbon. Registered as a side effect of importing this module.
|
|
3
|
+
import { Carbon } from "@zerotal/core/carbon";
|
|
4
|
+
import { registerSynth } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
registerSynth({
|
|
7
|
+
key: "cbn",
|
|
8
|
+
|
|
9
|
+
match(value): value is Carbon {
|
|
10
|
+
return value instanceof Carbon;
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
dehydrate(value: Carbon, _meta) {
|
|
14
|
+
return value.toISOString();
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
hydrate(data, _meta) {
|
|
18
|
+
return new Carbon(data as string);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Synth (key "col") for arrays of ORM models: dehydrates a non-empty array of
|
|
2
|
+
// registered models to their ids (tagged with the model class name) and hydrates
|
|
3
|
+
// it back with a single `whereIn("id", ids)` query. Registered as a side effect
|
|
4
|
+
// of importing this module. Matches only when the array's first element is a
|
|
5
|
+
// registered model, so plain arrays fall through to the recursive serializer.
|
|
6
|
+
import type { BaseModel } from "@zerotal/orm";
|
|
7
|
+
import { registerSynth } from "./index.ts";
|
|
8
|
+
import { _modelRegistry, _modelClassToName } from "./ModelSynth.ts";
|
|
9
|
+
|
|
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
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
registerSynth({
|
|
20
|
+
key: "col",
|
|
21
|
+
|
|
22
|
+
match(value): value is BaseModel[] {
|
|
23
|
+
if (!Array.isArray(value) || value.length === 0) return false;
|
|
24
|
+
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).
|
|
28
|
+
const ctor = (first as object).constructor as typeof BaseModel;
|
|
29
|
+
if (!_modelClassToName.has(ctor)) return false;
|
|
30
|
+
// Only claim a homogeneous array. A mixed array would be dehydrated under a
|
|
31
|
+
// single class tag and silently lose the odd-one-out on hydrate, so let it
|
|
32
|
+
// fall through to the recursive serializer, which handles each model via
|
|
33
|
+
// ModelSynth.
|
|
34
|
+
return value.every((m) => isModelOf(m, ctor));
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
dehydrate(value: BaseModel[], meta) {
|
|
38
|
+
const ctor = (value[0] as object).constructor as typeof BaseModel;
|
|
39
|
+
meta["class"] = _modelClassToName.get(ctor)!;
|
|
40
|
+
return value.map((m) => (m as unknown as Record<string, unknown>)["id"]);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async hydrate(data, meta) {
|
|
44
|
+
const cls = meta["class"] as string | undefined;
|
|
45
|
+
if (!cls) throw new Error("[Flow] CollectionSynth: missing class in meta");
|
|
46
|
+
const Model = _modelRegistry.get(cls);
|
|
47
|
+
if (!Model) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[Flow] Model "${cls}" is not registered. ` +
|
|
50
|
+
`Add it to your Component's static models property.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const ids = data as unknown[];
|
|
54
|
+
return (
|
|
55
|
+
Model as unknown as {
|
|
56
|
+
whereIn(col: string, vals: unknown[]): { get(): Promise<BaseModel[]> };
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
.whereIn("id", ids)
|
|
60
|
+
.get();
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Synth (key "dt") for plain JS Dates: serializes to an ISO string and hydrates
|
|
2
|
+
// back to a Date. Because Carbon extends Date, this synth deliberately excludes
|
|
3
|
+
// Carbon instances so they route to CarbonSynth instead. Registered as a side
|
|
4
|
+
// effect of importing this module.
|
|
5
|
+
import { Carbon } from "@zerotal/core/carbon";
|
|
6
|
+
import { registerSynth } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
registerSynth({
|
|
9
|
+
key: "dt",
|
|
10
|
+
|
|
11
|
+
match(value): value is Date {
|
|
12
|
+
// Plain Date — Carbon is a subclass of Date so check it first via CarbonSynth;
|
|
13
|
+
// this synth only fires for non-Carbon Dates.
|
|
14
|
+
return value instanceof Date && !(value instanceof Carbon);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
dehydrate(value: Date, _meta) {
|
|
18
|
+
return value.toISOString();
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
hydrate(data, _meta) {
|
|
22
|
+
return new Date(data as string);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BaseModel } from "@zerotal/orm";
|
|
2
|
+
import { registerSynth } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
// Models register themselves here when their class is imported.
|
|
5
|
+
// Populated via registerFlowModel() called in the Component's static `models` map.
|
|
6
|
+
export const _modelRegistry = new Map<string, typeof BaseModel>();
|
|
7
|
+
|
|
8
|
+
// Reverse map: class constructor → stable developer-assigned name.
|
|
9
|
+
// Using this instead of constructor.name prevents breakage under minification,
|
|
10
|
+
// where `Post` might be mangled to `n`. Exported so CollectionSynth shares the
|
|
11
|
+
// same minification-safe lookup.
|
|
12
|
+
export const _modelClassToName = new Map<typeof BaseModel, string>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
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`.
|
|
18
|
+
*
|
|
19
|
+
* @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.
|
|
24
|
+
*
|
|
25
|
+
* @param name - Stable, developer-assigned name recorded in the snapshot's `meta.class`.
|
|
26
|
+
* @param ModelClass - The `BaseModel` subclass to associate with `name`.
|
|
27
|
+
*
|
|
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
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* // Or register manually (e.g. outside a page's static models):
|
|
36
|
+
* registerFlowModel("Post", Post);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function registerFlowModel(name: string, ModelClass: typeof BaseModel): void {
|
|
40
|
+
_modelRegistry.set(name, ModelClass);
|
|
41
|
+
_modelClassToName.set(ModelClass, name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
registerSynth({
|
|
45
|
+
key: "mdl",
|
|
46
|
+
|
|
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
|
+
},
|
|
57
|
+
|
|
58
|
+
dehydrate(value: BaseModel, meta) {
|
|
59
|
+
// Use the stable registered name rather than constructor.name, which is
|
|
60
|
+
// 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"];
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async hydrate(data, meta) {
|
|
66
|
+
const cls = meta["class"] as string | undefined;
|
|
67
|
+
if (!cls) throw new Error("[Flow] ModelSynth: missing class in meta");
|
|
68
|
+
const Model = _modelRegistry.get(cls);
|
|
69
|
+
if (!Model) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[Flow] Model "${cls}" is not registered. ` +
|
|
72
|
+
`Add it to your Component's static models property.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// BaseModel.findOrFail is a static method
|
|
76
|
+
return (Model as unknown as { findOrFail(id: unknown): Promise<BaseModel> }).findOrFail(data);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { registerSynth } from "./index.ts";
|
|
2
|
+
import { TemporaryUploadedFile, type TufData } from "../uploads/TemporaryUploadedFile.ts";
|
|
3
|
+
|
|
4
|
+
// Synth (key "tuf") that lets a TemporaryUploadedFile property round-trip through
|
|
5
|
+
// the (HMAC-signed) snapshot: dehydrates to its plain TufData ref and hydrates via
|
|
6
|
+
// fromTrustedRef (no per-ref signature check, since the whole snapshot is signed).
|
|
7
|
+
// Registered as a side effect of importing this module.
|
|
8
|
+
registerSynth({
|
|
9
|
+
key: "tuf",
|
|
10
|
+
match(value): value is TemporaryUploadedFile {
|
|
11
|
+
return value instanceof TemporaryUploadedFile;
|
|
12
|
+
},
|
|
13
|
+
dehydrate(value: TemporaryUploadedFile) {
|
|
14
|
+
return value.toRef();
|
|
15
|
+
},
|
|
16
|
+
hydrate(data) {
|
|
17
|
+
return TemporaryUploadedFile.fromTrustedRef(data as TufData);
|
|
18
|
+
},
|
|
19
|
+
});
|