@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
package/src/mixins.ts ADDED
@@ -0,0 +1,199 @@
1
+ // ── Component mixins ────────────────────────────────────────────────────────
2
+ //
3
+ // `ComponentWith(...)` composes any number of feature mixins on top of `Component`,
4
+ // so a page can be broken into focused, reusable behaviours (pagination, file uploads,
5
+ // sortable tables, …) that stack left-to-right instead of bloating one class — the
6
+ // Flow counterpart of the ORM's `BaseModelWith(...)`:
7
+ //
8
+ // // before — one class carrying every feature inline, base repeated per mixin
9
+ // class UsersPage extends FileUploads(Pagination(Component)) { … }
10
+ //
11
+ // // after — flat, left-to-right, base baked in
12
+ // class UsersPage extends ComponentWith(Pagination, FileUploads) {
13
+ // async render() { … } // the page only owns its own markup + state
14
+ // }
15
+ //
16
+ // A Component mixin is the canonical generic form below — note it extends an ABSTRACT
17
+ // base and returns an `abstract class`, so it doesn't have to implement Component's
18
+ // abstract `render()` (the final page supplies it):
19
+ //
20
+ // // app/flow/mixins/file-upload.ts
21
+ // import { Component, expose, type Constructor } from "@zerotal/flow";
22
+ //
23
+ // export function FileUpload<T extends Constructor<Component>>(Base: T) {
24
+ // abstract class WithUpload extends Base {
25
+ // @expose uploading = false;
26
+ // @expose async upload() { … }
27
+ // }
28
+ // return WithUpload;
29
+ // }
30
+ //
31
+ // Because each mixin returns `class extends Base`, Component's full surface (`flash()`,
32
+ // `redirect()`, `validate()`, the `this.set`/`refresh`/… client magics, …) and every other
33
+ // mixin's members flow through to the final page — fully type-checked. The shipped
34
+ // `Pagination` mixin (see ./pagination.ts) is written exactly this way and composes here:
35
+ // `ComponentWith(Pagination)`.
36
+ //
37
+ // Mixin `@expose`/`@locked` props register on the mixin's prototype, which sits in the page's
38
+ // prototype chain — so they're discovered at runtime exactly like props declared on the page
39
+ // itself (snapshot, reactivity, client writes, `@url` sync all work). Note: pages composed
40
+ // with `ComponentWith(...)` render via the runtime path rather than the AOT compiler (which
41
+ // only statically sees a page's own `extends Component` + locally-declared members) — the same
42
+ // fallback complex pages already use; behaviour is identical, you just don't get the
43
+ // ahead-of-time compile step for that page.
44
+
45
+ import { Component } from "./Component.ts";
46
+
47
+ /**
48
+ * A class constructor. Abstract-aware (`abstract new …`) so the abstract `Component` base —
49
+ * and mixin classes that don't yet implement `render()` — satisfy it. Use `Constructor<Component>`
50
+ * as a mixin's base bound to require a Component lineage.
51
+ */
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
53
+ export type Constructor<T = object> = abstract new (...args: any[]) => T;
54
+
55
+ /**
56
+ * A Component mixin: receives a base constructor and returns an extended one. Authors write
57
+ * the generic form so the mixin composes onto whatever base it is given:
58
+ *
59
+ * export function FileUpload<T extends Constructor<Component>>(Base: T) {
60
+ * abstract class WithUpload extends Base { … }
61
+ * return WithUpload;
62
+ * }
63
+ */
64
+ export type Mixin<TIn extends Constructor = Constructor, TOut extends Constructor = Constructor> = (
65
+ Base: TIn,
66
+ ) => TOut;
67
+
68
+ type Base = typeof Component;
69
+
70
+ /**
71
+ * Compose one or more feature {@link Mixin}s onto {@link Component}, folding them left-to-right
72
+ * so a page can be built from focused, reusable behaviours instead of one bloated class.
73
+ *
74
+ * @remarks
75
+ * Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
76
+ * surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
77
+ * `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
78
+ * register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
79
+ * reactivity, and client writes all work. Overloads cover 1–8 mixins; nest another
80
+ * `ComponentWith(...)` call for more. Pages composed this way render via the runtime path rather
81
+ * than the AOT compiler.
82
+ *
83
+ * @param mixins mixin factories applied left-to-right (each takes the prior result as its base).
84
+ * @returns a class extending `Component` with every mixin's members mixed in.
85
+ *
86
+ * @example
87
+ * ```tsx
88
+ * // app/flow/mixins/pagination.ts
89
+ * import { Component, expose, type Constructor } from "@zerotal/flow";
90
+ *
91
+ * export function Pagination<T extends Constructor<Component>>(Base: T) {
92
+ * abstract class WithPagination extends Base {
93
+ * @expose page = 1;
94
+ * @expose next() { this.page++; }
95
+ * }
96
+ * return WithPagination;
97
+ * }
98
+ *
99
+ * // app/flow/UsersPage.tsx
100
+ * class UsersPage extends ComponentWith(Pagination) {
101
+ * override async render() {
102
+ * return <div data-flow-root>Page {this.page}</div>;
103
+ * }
104
+ * }
105
+ * ```
106
+ */
107
+ export function ComponentWith<A extends Constructor>(a: (base: Base) => A): A;
108
+ export function ComponentWith<A extends Constructor, B extends Constructor>(
109
+ a: (base: Base) => A,
110
+ b: (base: A) => B,
111
+ ): B;
112
+ export function ComponentWith<A extends Constructor, B extends Constructor, C extends Constructor>(
113
+ a: (base: Base) => A,
114
+ b: (base: A) => B,
115
+ c: (base: B) => C,
116
+ ): C;
117
+ export function ComponentWith<
118
+ A extends Constructor,
119
+ B extends Constructor,
120
+ C extends Constructor,
121
+ D extends Constructor,
122
+ >(a: (base: Base) => A, b: (base: A) => B, c: (base: B) => C, d: (base: C) => D): D;
123
+ export function ComponentWith<
124
+ A extends Constructor,
125
+ B extends Constructor,
126
+ C extends Constructor,
127
+ D extends Constructor,
128
+ E extends Constructor,
129
+ >(
130
+ a: (base: Base) => A,
131
+ b: (base: A) => B,
132
+ c: (base: B) => C,
133
+ d: (base: C) => D,
134
+ e: (base: D) => E,
135
+ ): E;
136
+ export function ComponentWith<
137
+ A extends Constructor,
138
+ B extends Constructor,
139
+ C extends Constructor,
140
+ D extends Constructor,
141
+ E extends Constructor,
142
+ F extends Constructor,
143
+ >(
144
+ a: (base: Base) => A,
145
+ b: (base: A) => B,
146
+ c: (base: B) => C,
147
+ d: (base: C) => D,
148
+ e: (base: D) => E,
149
+ f: (base: E) => F,
150
+ ): F;
151
+ export function ComponentWith<
152
+ A extends Constructor,
153
+ B extends Constructor,
154
+ C extends Constructor,
155
+ D extends Constructor,
156
+ E extends Constructor,
157
+ F extends Constructor,
158
+ G extends Constructor,
159
+ >(
160
+ a: (base: Base) => A,
161
+ b: (base: A) => B,
162
+ c: (base: B) => C,
163
+ d: (base: C) => D,
164
+ e: (base: D) => E,
165
+ f: (base: E) => F,
166
+ g: (base: F) => G,
167
+ ): G;
168
+ export function ComponentWith<
169
+ A extends Constructor,
170
+ B extends Constructor,
171
+ C extends Constructor,
172
+ D extends Constructor,
173
+ E extends Constructor,
174
+ F extends Constructor,
175
+ G extends Constructor,
176
+ H extends Constructor,
177
+ >(
178
+ a: (base: Base) => A,
179
+ b: (base: A) => B,
180
+ c: (base: B) => C,
181
+ d: (base: C) => D,
182
+ e: (base: D) => E,
183
+ f: (base: E) => F,
184
+ g: (base: F) => G,
185
+ h: (base: G) => H,
186
+ ): H;
187
+
188
+ // Implementation: fold the mixins over Component, left to right. The base param is `any` so
189
+ // each typed overload above (whose base is the narrower `typeof Component` or a prior mixin's
190
+ // result) stays assignable to this implementation signature.
191
+ export function ComponentWith(
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic mixin folder
193
+ ...mixins: Array<(base: any) => Constructor>
194
+ ): Constructor {
195
+ return mixins.reduce<Constructor>(
196
+ (acc, mixin) => mixin(acc),
197
+ Component as unknown as Constructor,
198
+ );
199
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Flow → observer bridges. Flow emits its own realtime framework events
3
+ * (`WebSocketConnected` / `WebSocketDisconnected` / `FlowActionHandled`) on the
4
+ * core `FrameworkEvents` bus; this module forwards them to whichever observer
5
+ * packages are installed.
6
+ *
7
+ * A Flow action round-trips like a request, so its monitor record carries the
8
+ * same correlated per-request state (queries, N+1 flag, custom context) that other
9
+ * packages buffered against the action's HttpContext — drained here through the
10
+ * store's `collectRequestState`.
11
+ *
12
+ * Each observer's write surface is resolved from the container by binding key and
13
+ * typed through a local structural interface, so Flow depends on none of the
14
+ * observer packages — installing or removing an observer requires no change here.
15
+ */
16
+ import { FrameworkEvents } from "@zerotal/core";
17
+ import type { Application } from "@zerotal/core";
18
+ import { WebSocketConnected, WebSocketDisconnected, FlowActionHandled } from "./frameworkEvents.ts";
19
+
20
+ /** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
21
+ interface MonitorSink {
22
+ recordEvent(e: {
23
+ kind: string;
24
+ label: string;
25
+ status?: "ok" | "warn" | "bad" | "info";
26
+ route?: string | null;
27
+ data?: Record<string, unknown>;
28
+ }): void;
29
+ collectRequestState(ctx: object): {
30
+ queries: unknown[];
31
+ nplus: boolean;
32
+ context: Record<string, unknown>;
33
+ payload: unknown;
34
+ };
35
+ }
36
+
37
+ /** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
38
+ interface DevtoolsSink {
39
+ channel(descriptor: {
40
+ id: string;
41
+ label: string;
42
+ badge?: string;
43
+ title?: string;
44
+ meta?: string[];
45
+ warn?: string;
46
+ order?: number;
47
+ }): void;
48
+ record(ctx: object, channel: string, entry: Record<string, unknown>): void;
49
+ }
50
+
51
+ // Framework/asset noise the panel shouldn't chart as realtime traffic.
52
+ const IGNORE_PREFIXES = ["/monitor", "/metrics", "/__flow", "/__zerotal", "/__dev", "/health"];
53
+
54
+ /** Raw request path from an HttpContext (best-effort). */
55
+ function _rawPath(ctx: object): string {
56
+ return (ctx as { url?: { pathname?: string } }).url?.pathname ?? "/";
57
+ }
58
+
59
+ /** Authenticated user identity (email → id → name), or null when unauthenticated. */
60
+ function _ctxUser(ctx: object): string | null {
61
+ const u = (ctx as { user?: unknown }).user;
62
+ if (u == null) return null;
63
+ if (typeof u === "string" || typeof u === "number") return String(u);
64
+ if (typeof u === "object") {
65
+ const o = u as Record<string, unknown>;
66
+ const v = o.email ?? o.id ?? o.name;
67
+ return v != null ? String(v) : null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Resolve an observer's write surface, or `undefined` when that package is not
74
+ * installed. The cast is confined here: each binding is declared by the package
75
+ * that owns it, and Flow deliberately depends on none of them.
76
+ */
77
+ function _observer<T>(app: Application, binding: string): T | undefined {
78
+ return app.container.tryMake(binding as never) as T | undefined;
79
+ }
80
+
81
+ /**
82
+ * Subscribe Flow's realtime events to every installed observer. Returns a disposer
83
+ * that removes every subscription; call it from the Flow provider's `onStopping()`.
84
+ */
85
+ export function installFlowObservability(app: Application): () => void {
86
+ const unsubs: Array<() => void> = [];
87
+
88
+ const store = _observer<MonitorSink>(app, "monitor.store");
89
+ if (store) {
90
+ unsubs.push(
91
+ FrameworkEvents.on(WebSocketConnected, () =>
92
+ store.recordEvent({ kind: "ws", label: "connect", status: "info" }),
93
+ ),
94
+ FrameworkEvents.on(WebSocketDisconnected, () =>
95
+ store.recordEvent({ kind: "ws", label: "disconnect", status: "info" }),
96
+ ),
97
+ FrameworkEvents.on(FlowActionHandled, (e) => {
98
+ const ctx = e.ctx;
99
+ // Skip the monitor panel's own components — its poll/refresh round-trips would
100
+ // otherwise dominate the realtime view. Draining discards their buffered state.
101
+ if (ctx && IGNORE_PREFIXES.some((p) => _rawPath(ctx).startsWith(p))) {
102
+ store.collectRequestState(ctx);
103
+ return;
104
+ }
105
+ // Pull the same correlated context an HTTP request gets: the user (set by
106
+ // re-run middleware), the client IP, and the SQL queries tied to this action.
107
+ const state = ctx
108
+ ? store.collectRequestState(ctx)
109
+ : { queries: [] as unknown[], nplus: false, context: {} as Record<string, unknown> };
110
+ store.recordEvent({
111
+ kind: "ws",
112
+ label: "action",
113
+ status: e.ok ? "ok" : "bad",
114
+ route: e.component,
115
+ data: {
116
+ component: e.component,
117
+ action: e.action,
118
+ ms: Math.round(e.durationMs),
119
+ ok: e.ok,
120
+ user: ctx ? _ctxUser(ctx) : null,
121
+ ip: e.ip,
122
+ nplus: state.nplus,
123
+ // Process heap at action completion — the same per-request memory proxy
124
+ // HTTP requests record, captured synchronously in the emit call stack.
125
+ memKb: Math.round(process.memoryUsage().heapUsed / 1024),
126
+ nQueries: state.queries.length,
127
+ queries: state.queries.slice(0, 25),
128
+ context: state.context,
129
+ },
130
+ });
131
+ }),
132
+ );
133
+ }
134
+
135
+ const trace = _observer<DevtoolsSink>(app, "devtools.trace");
136
+ if (trace) {
137
+ trace.channel({
138
+ id: "flow",
139
+ label: "Flow",
140
+ badge: "component",
141
+ title: "action",
142
+ meta: ["durationMs", "ip"],
143
+ warn: "failed",
144
+ order: 20,
145
+ });
146
+
147
+ unsubs.push(
148
+ FrameworkEvents.on(FlowActionHandled, (e) => {
149
+ // 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.
151
+ if (!e.ctx) return;
152
+ if (IGNORE_PREFIXES.some((p) => _rawPath(e.ctx as object).startsWith(p))) return;
153
+ trace.record(e.ctx as object, "flow", {
154
+ component: e.component,
155
+ action: e.action,
156
+ durationMs: Math.round(e.durationMs),
157
+ ip: e.ip,
158
+ ...(e.ok ? {} : { failed: true }),
159
+ });
160
+ }),
161
+ );
162
+ }
163
+
164
+ return () => {
165
+ for (const unsub of unsubs) unsub();
166
+ };
167
+ }
@@ -0,0 +1,300 @@
1
+ // ── In-memory pagination ──────────────────────────────────────────────────────
2
+ //
3
+ // Pairs with `@expose @url page` to paginate an in-memory array (filtered/derived
4
+ // data) inside render() or onMount(). For database queries, prefer the ORM's
5
+ // query builder `.paginate(perPage, page)` — this helper is for arrays you already
6
+ // hold in memory. The result shape mirrors the ORM paginator for consistency.
7
+
8
+ /**
9
+ * The result of paginating an in-memory array — one page of `data` plus the
10
+ * surrounding metadata a view needs to render a pager.
11
+ *
12
+ * The shape intentionally mirrors the ORM query builder's paginator, so a
13
+ * template can consume either interchangeably.
14
+ *
15
+ * @typeParam T - Element type of the paginated array.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const page = paginate(posts, 2, 10);
20
+ * page.data; // the 10 items on page 2
21
+ * page.from; // 11
22
+ * page.hasMorePages; // true if a page 3 exists
23
+ * page.elements(); // [1, 2, 3, '...', 20]
24
+ * ```
25
+ */
26
+ export interface Paginator<T> {
27
+ /**
28
+ * The items on the current page (a slice of the input array).
29
+ * @category Access
30
+ */
31
+ data: T[];
32
+ /**
33
+ * Total item count across all pages.
34
+ * @category Metadata
35
+ */
36
+ total: number;
37
+ /**
38
+ * Items per page (the effective, floored value; at least 1).
39
+ * @category Metadata
40
+ */
41
+ perPage: number;
42
+ /**
43
+ * Current page (1-based, clamped to a valid range).
44
+ * @category Metadata
45
+ */
46
+ page: number;
47
+ /**
48
+ * Last page number (>= 1; 1 even when there are no items).
49
+ * @category Metadata
50
+ */
51
+ lastPage: number;
52
+ /**
53
+ * 1-based index of the first item on this page (0 when empty).
54
+ * @category Metadata
55
+ */
56
+ from: number;
57
+ /**
58
+ * 1-based index of the last item on this page (0 when empty).
59
+ * @category Metadata
60
+ */
61
+ to: number;
62
+ /**
63
+ * True when on page 1.
64
+ * @category Metadata
65
+ */
66
+ onFirstPage: boolean;
67
+ /**
68
+ * True when a further page exists.
69
+ * @category Metadata
70
+ */
71
+ hasMorePages: boolean;
72
+ /**
73
+ * Page-number window for rendering a numbered pager, with `'...'` gaps —
74
+ * e.g. `[1, '...', 4, 5, 6, '...', 20]`. The first and last pages are always
75
+ * included.
76
+ *
77
+ * @param each - How many page links to show on each side of the current page (default `1`).
78
+ * @returns A list of page numbers interleaved with `'...'` for elided ranges.
79
+ * @category Navigation
80
+ */
81
+ elements(each?: number): (number | "...")[];
82
+ }
83
+
84
+ /**
85
+ * Paginate an in-memory array — the array counterpart to the ORM's
86
+ * `.paginate(perPage, page)`. Use this for filtered/derived data you already
87
+ * hold in memory (e.g. inside `render()` or `onMount()`); for database queries
88
+ * prefer the query builder's paginator.
89
+ *
90
+ * `page` is clamped into `[1, lastPage]` and `perPage` is floored to at least 1,
91
+ * so out-of-range input never throws.
92
+ *
93
+ * @typeParam T - Element type of `items`.
94
+ * @param items - The full, already-in-memory collection to page over.
95
+ * @param page - 1-based page to return (clamped; default `1`).
96
+ * @param perPage - Items per page (floored to >= 1; default `15`).
97
+ * @returns A {@link Paginator} for the requested page.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const results = posts.filter((p) => p.published);
102
+ * const page = paginate(results, this.page, 10);
103
+ * for (const post of page.data) { ... }
104
+ * ```
105
+ */
106
+ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
107
+ const total = items.length;
108
+ perPage = Math.max(1, Math.floor(perPage));
109
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
110
+ const currentPage = Math.min(Math.max(1, Math.floor(page) || 1), lastPage);
111
+ const offset = (currentPage - 1) * perPage;
112
+ const data = items.slice(offset, offset + perPage);
113
+ return {
114
+ data,
115
+ total,
116
+ perPage,
117
+ page: currentPage,
118
+ lastPage,
119
+ from: total === 0 ? 0 : offset + 1,
120
+ to: offset + data.length,
121
+ onFirstPage: currentPage <= 1,
122
+ hasMorePages: currentPage < lastPage,
123
+ elements(each = 1) {
124
+ return pageElements(currentPage, lastPage, each);
125
+ },
126
+ };
127
+ }
128
+
129
+ // ── Pagination mixin ──────────────────────────────────────────────────────────
130
+ //
131
+ // The TypeScript equivalent of Livewire's `WithPagination` trait: compose it onto a
132
+ // component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
133
+ // `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
134
+ // the base Component.
135
+ //
136
+ // class PostsPage extends ComponentWith(Pagination) {
137
+ // @locked posts: Post[] = [];
138
+ // async onMount() { this.posts = await Post.all(); }
139
+ // async render() {
140
+ // const posts = await Post.paginate(10); // uses this.page
141
+ // return <div>…{p.data.map(…)}… pager with this.nextPage / ?page= links</div>;
142
+ // }
143
+ // }
144
+ //
145
+ // `page` is @expose + @url (in the snapshot, synced to ?page=). The nav methods are
146
+ // @expose, so they're callable from the browser (onClick={this.nextPage}). Decorators
147
+ // applied here register on the mixin's prototype, which the component's prototype-chain
148
+ // walk already traverses — so it "just works".
149
+ //
150
+ // Named paginators (Livewire's `pageName`): every nav method accepts an optional page
151
+ // name so a single page can drive more than one independent paginator —
152
+ // `this.nextPage("invoices")`, `Invoice.paginate(10, undefined, "invoices")`. The default
153
+ // paginator ("page") is the URL-synced one; named paginators live in the snapshot.
154
+ //
155
+ // Optional update hooks fire around a page change, mirroring Livewire — define any of
156
+ // `updatingPage(page, name)` / `updatedPage(page, name)` (default paginator) or the
157
+ // generic `updatingPaginators(page, name)` / `updatedPaginators(page, name)`.
158
+
159
+ import { Component } from "./Component.ts";
160
+ import { expose, url } from "./decorators.ts";
161
+ import { setCurrentPageResolver, pageElements, type HttpContext } from "@zerotal/core";
162
+
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ type AbstractComponentCtor = abstract new (...args: any[]) => Component;
165
+
166
+ /** Optional, user-defined pagination lifecycle hooks (all optional). */
167
+ interface PaginationHooks {
168
+ updatingPage?(page: number, pageName: string): void;
169
+ updatedPage?(page: number, pageName: string): void;
170
+ updatingPaginators?(page: number, pageName: string): void;
171
+ updatedPaginators?(page: number, pageName: string): void;
172
+ }
173
+
174
+ const DEFAULT_PAGE = "page";
175
+
176
+ /**
177
+ * Component mixin that adds URL-synced pagination state and navigation actions —
178
+ * flow's equivalent of Livewire's `WithPagination` trait.
179
+ *
180
+ * @remarks
181
+ * Composing this onto a component adds:
182
+ * - a `page` field, `@expose`d and `@url`-synced to the `?page=` query string;
183
+ * - a `paginators` map holding the page of each NAMED paginator (snapshot-only),
184
+ * so a single component can drive several independent pagers by passing a
185
+ * `pageName` (e.g. `nextPage("invoices")`, `paginate(rows, 10, "invoices")`);
186
+ * - `@expose`d navigation actions callable straight from the browser:
187
+ * {@link Paginated.gotoPage | gotoPage}, `resetPage`, `nextPage`,
188
+ * `previousPage`;
189
+ * - a `paginate(items, perPage, pageName?)` helper that slices an in-memory array
190
+ * at the current page.
191
+ *
192
+ * All page writes clamp to `>= 1`. Optional lifecycle hooks fire around a page
193
+ * change if you define them: `updatingPage`/`updatedPage` (default paginator) and
194
+ * the generic `updatingPaginators`/`updatedPaginators`. Call `resetPage()` when a
195
+ * filter or search term changes so the user isn't stranded on an empty page.
196
+ *
197
+ * @typeParam TBase - The (abstract) Component constructor being extended.
198
+ * @param Base - The component class to mix into.
199
+ * @returns A subclass of `Base` with pagination state and navigation.
200
+ *
201
+ * @example
202
+ * ```tsx
203
+ * class PostsPage extends ComponentWith(Pagination) {
204
+ * @locked posts: Post[] = [];
205
+ * async onMount() { this.posts = await Post.all(); }
206
+ * async render() {
207
+ * const posts = await Post.paginate(10); // uses this.page
208
+ * return (
209
+ * <div>
210
+ * {p.data.map((post) => <PostCard post={post} />)}
211
+ * <button onClick={this.previousPage} disabled={p.onFirstPage}>Prev</button>
212
+ * <button onClick={this.nextPage} disabled={!p.hasMorePages}>Next</button>
213
+ * </div>
214
+ * );
215
+ * }
216
+ * }
217
+ * ```
218
+ */
219
+ export function Pagination<TBase extends AbstractComponentCtor>(Base: TBase) {
220
+ abstract class Paginated extends Base {
221
+ /** Current page of the default paginator, synced to the `?page=` query string. */
222
+ @expose @url page = 1;
223
+
224
+ /** Current page of each NAMED paginator (Livewire `pageName`), keyed by name. Snapshot-only. */
225
+ @expose paginators: Record<string, number> = {};
226
+
227
+ /**
228
+ * Read the current page for a paginator (default or named).
229
+ * @param pageName - Named paginator; omit for the default URL-synced one.
230
+ * @returns The current 1-based page (>= 1).
231
+ * @category Access
232
+ */
233
+ pageFor(pageName: string = DEFAULT_PAGE): number {
234
+ if (pageName === DEFAULT_PAGE) return this.page;
235
+ return this.paginators[pageName] ?? 1;
236
+ }
237
+
238
+ /**
239
+ * Point database pagination at this component's page, for the rest of the request.
240
+ *
241
+ * With it, `Post.paginate(10)` inside the component returns the page the user is on —
242
+ * no page argument to thread through. It runs on every request because a WebSocket
243
+ * action carries no `?page=` for the query string to fall back on; the page lives here.
244
+ */
245
+ override async onBoot(ctx?: HttpContext): Promise<void> {
246
+ await super.onBoot(ctx);
247
+ setCurrentPageResolver((pageName) => this.pageFor(pageName));
248
+ }
249
+
250
+ /** Write a paginator's page (clamped to >= 1), firing the update hooks around it.
251
+ * Internal — not `@expose`, so it's never callable from the client. */
252
+ _setPageValue(pageName: string, value: number): void {
253
+ const next = Math.max(1, Math.floor(Number(value)) || 1);
254
+ if (next === this.pageFor(pageName)) return;
255
+
256
+ const hooks = this as unknown as PaginationHooks;
257
+ hooks.updatingPaginators?.(next, pageName);
258
+ if (pageName === DEFAULT_PAGE) hooks.updatingPage?.(next, pageName);
259
+
260
+ if (pageName === DEFAULT_PAGE) this.page = next;
261
+ else this.paginators = { ...this.paginators, [pageName]: next };
262
+
263
+ if (pageName === DEFAULT_PAGE) hooks.updatedPage?.(next, pageName);
264
+ hooks.updatedPaginators?.(next, pageName);
265
+ }
266
+
267
+ /**
268
+ * Jump to a specific page (clamped to >= 1).
269
+ * @param p - Target 1-based page.
270
+ * @param pageName - Named paginator; omit for the default.
271
+ * @category Navigation
272
+ */
273
+ @expose gotoPage(p: number, pageName: string = DEFAULT_PAGE): void {
274
+ this._setPageValue(pageName, p);
275
+ }
276
+ /**
277
+ * Reset to page 1 — call this when filters/search change so the user isn't
278
+ * left on a now-empty page.
279
+ * @category Navigation
280
+ */
281
+ @expose resetPage(pageName: string = DEFAULT_PAGE): void {
282
+ this._setPageValue(pageName, 1);
283
+ }
284
+ /**
285
+ * Advance to the next page.
286
+ * @category Navigation
287
+ */
288
+ @expose nextPage(pageName: string = DEFAULT_PAGE): void {
289
+ this._setPageValue(pageName, this.pageFor(pageName) + 1);
290
+ }
291
+ /**
292
+ * Go back one page (clamped to >= 1).
293
+ * @category Navigation
294
+ */
295
+ @expose previousPage(pageName: string = DEFAULT_PAGE): void {
296
+ this._setPageValue(pageName, this.pageFor(pageName) - 1);
297
+ }
298
+ }
299
+ return Paginated;
300
+ }