@zerotal/admin 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 (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Build a Flow {@link Form} subclass from a resource's form schema for a given
3
+ * mode. The form's own enumerable properties are the visible field keys (so
4
+ * `flow:model="form.<key>"` binds + survives WebSocket round-trips), and its
5
+ * `rules()` are derived from the fields' validators.
6
+ *
7
+ * Accepts the full schema (`FormComponent[]` — fields and/or sections); the
8
+ * sections are flattened to fields for the generated form. The page renders the
9
+ * layout; the form only holds state + validation.
10
+ *
11
+ * One class is generated per (resource, mode) at boot and reused; a stable class
12
+ * name lets the Form synth reconstruct it on hydration.
13
+ */
14
+ import { Form } from "@zerotal/flow";
15
+ import type { RuleBuilder, FieldRule } from "@zerotal/validator";
16
+ import type { Field, FieldMode } from "./Field.ts";
17
+ import { type FormComponent, flattenFields } from "./Section.ts";
18
+
19
+ export type ResourceFormClass = new () => Form & Record<string, unknown>;
20
+
21
+ export function makeResourceForm(
22
+ schema: FormComponent[],
23
+ mode: FieldMode,
24
+ className: string,
25
+ ): { FormClass: ResourceFormClass; fields: Field[] } {
26
+ const visible = flattenFields(schema).filter((f) => f.visibleIn(mode));
27
+ const defaults: Record<string, unknown> = {};
28
+ for (const f of visible) defaults[f._key] = f.defaultValue();
29
+
30
+ class ResourceForm extends Form {
31
+ constructor() {
32
+ super();
33
+ Object.assign(this, structuredClone(defaults));
34
+ }
35
+
36
+ override rules(v: RuleBuilder): Record<string, FieldRule> {
37
+ const out: Record<string, FieldRule> = {};
38
+ for (const f of visible) out[f._key] = f.buildRule(v);
39
+ return out;
40
+ }
41
+ }
42
+
43
+ Object.defineProperty(ResourceForm, "name", { value: className });
44
+ // Instantiate once so the Form registry knows this class for hydration.
45
+ new ResourceForm();
46
+
47
+ return { FormClass: ResourceForm as unknown as ResourceFormClass, fields: visible };
48
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Form layout — group fields into titled, multi-column sections, tabs, wizard
3
+ * steps and splits. A resource's `form()` may return a flat list of fields *or*
4
+ * a mix of fields and sections; loose fields are gathered into a default
5
+ * section. Fields and sections compose into the same `FormComponent[]`.
6
+ *
7
+ * static form() {
8
+ * return [
9
+ * formSection("Profile").description("Public details").columns(2).schema([
10
+ * textInput("name").required(),
11
+ * textInput("email").email().required(),
12
+ * ]),
13
+ * formSection("Security").schema([
14
+ * textInput("password").password().confirmed().visibleOn("create"),
15
+ * ]),
16
+ * ];
17
+ * }
18
+ */
19
+ import { Field } from "./Field.ts";
20
+
21
+ export class FormSection {
22
+ /** @internal */ _heading?: string | undefined;
23
+ /** @internal */ _description?: string;
24
+ /** @internal */ _icon?: string;
25
+ /** @internal */ _columns = 2;
26
+ /** @internal */ _collapsible = false;
27
+ /** @internal Render as a bordered <fieldset> with a <legend>. */
28
+ _fieldset = false;
29
+ /** @internal */ _fields: Field[] = [];
30
+
31
+ constructor(heading?: string) {
32
+ this._heading = heading;
33
+ }
34
+
35
+ static make(heading?: string): FormSection {
36
+ return new FormSection(heading);
37
+ }
38
+
39
+ heading(heading: string): this {
40
+ this._heading = heading;
41
+ return this;
42
+ }
43
+
44
+ description(description: string): this {
45
+ this._description = description;
46
+ return this;
47
+ }
48
+
49
+ icon(name: string): this {
50
+ this._icon = name;
51
+ return this;
52
+ }
53
+
54
+ /** Number of columns the fields flow into (1–4). */
55
+ columns(n: number): this {
56
+ this._columns = Math.min(4, Math.max(1, n));
57
+ return this;
58
+ }
59
+
60
+ collapsible(value = true): this {
61
+ this._collapsible = value;
62
+ return this;
63
+ }
64
+
65
+ /** The fields contained in this section. */
66
+ schema(fields: Field[]): this {
67
+ this._fields = fields;
68
+ return this;
69
+ }
70
+
71
+ getFields(): Field[] {
72
+ return this._fields;
73
+ }
74
+ }
75
+
76
+ // ── Tabs layout ─────────────────────────────────────────────────────────────────
77
+
78
+ export class FormTab {
79
+ /** @internal */ _label: string;
80
+ /** @internal */ _icon?: string;
81
+ /** @internal */ _columns = 2;
82
+ /** @internal */ _fields: Field[] = [];
83
+
84
+ constructor(label: string) {
85
+ this._label = label;
86
+ }
87
+
88
+ icon(name: string): this {
89
+ this._icon = name;
90
+ return this;
91
+ }
92
+
93
+ columns(n: number): this {
94
+ this._columns = Math.min(4, Math.max(1, n));
95
+ return this;
96
+ }
97
+
98
+ schema(fields: Field[]): this {
99
+ this._fields = fields;
100
+ return this;
101
+ }
102
+
103
+ getFields(): Field[] {
104
+ return this._fields;
105
+ }
106
+ }
107
+
108
+ /** A tabbed group of fields — switches client-side; all panels stay in the DOM. */
109
+ export class FormTabs {
110
+ /** @internal */ _tabs: FormTab[];
111
+ constructor(tabs: FormTab[]) {
112
+ this._tabs = tabs;
113
+ }
114
+ getFields(): Field[] {
115
+ return this._tabs.flatMap((t) => t.getFields());
116
+ }
117
+ }
118
+
119
+ /** Factory for a single tab. */
120
+ export function formTab(label: string): FormTab {
121
+ return new FormTab(label);
122
+ }
123
+
124
+ /** Factory for a tabbed layout. */
125
+ export function formTabs(tabs: FormTab[]): FormTabs {
126
+ return new FormTabs(tabs);
127
+ }
128
+
129
+ // ── Wizard layout ───────────────────────────────────────────────────────────────
130
+
131
+ export class WizardStep {
132
+ /** @internal */ _label: string;
133
+ /** @internal */ _description?: string;
134
+ /** @internal */ _icon?: string;
135
+ /** @internal */ _columns = 1;
136
+ /** @internal */ _fields: Field[] = [];
137
+
138
+ constructor(label: string) {
139
+ this._label = label;
140
+ }
141
+
142
+ description(text: string): this {
143
+ this._description = text;
144
+ return this;
145
+ }
146
+
147
+ icon(name: string): this {
148
+ this._icon = name;
149
+ return this;
150
+ }
151
+
152
+ columns(n: number): this {
153
+ this._columns = Math.min(4, Math.max(1, n));
154
+ return this;
155
+ }
156
+
157
+ schema(fields: Field[]): this {
158
+ this._fields = fields;
159
+ return this;
160
+ }
161
+
162
+ getFields(): Field[] {
163
+ return this._fields;
164
+ }
165
+ }
166
+
167
+ /** A multi-step wizard — validates each step before advancing; submits on the last. */
168
+ export class Wizard {
169
+ /** @internal */ _steps: WizardStep[];
170
+ constructor(steps: WizardStep[]) {
171
+ this._steps = steps;
172
+ }
173
+ getFields(): Field[] {
174
+ return this._steps.flatMap((s) => s.getFields());
175
+ }
176
+ }
177
+
178
+ /** Factory for a single wizard step. */
179
+ export function wizardStep(label: string): WizardStep {
180
+ return new WizardStep(label);
181
+ }
182
+
183
+ /** Factory for a wizard layout. */
184
+ export function wizard(steps: WizardStep[]): Wizard {
185
+ return new Wizard(steps);
186
+ }
187
+
188
+ // ── Fieldset / Split / Callout / Prime (minor layout primitives) ─────────────────
189
+
190
+ /** A section rendered as a bordered `<fieldset>` with a `<legend>`. */
191
+ export function fieldset(legend?: string): FormSection {
192
+ const s = new FormSection(legend);
193
+ s._fieldset = true;
194
+ s._columns = 1;
195
+ return s;
196
+ }
197
+
198
+ export type CalloutTone = "default" | "primary" | "success" | "warning" | "destructive";
199
+
200
+ /** A non-field callout/notice block in a form schema. */
201
+ export class Callout {
202
+ /** @internal */ _content: string;
203
+ /** @internal */ _tone: CalloutTone = "default";
204
+ /** @internal */ _icon?: string;
205
+ /** @internal */ _heading?: string | undefined;
206
+ constructor(content: string) {
207
+ this._content = content;
208
+ }
209
+ tone(tone: CalloutTone): this {
210
+ this._tone = tone;
211
+ return this;
212
+ }
213
+ icon(name: string): this {
214
+ this._icon = name;
215
+ return this;
216
+ }
217
+ heading(heading: string): this {
218
+ this._heading = heading;
219
+ return this;
220
+ }
221
+ }
222
+ export function callout(content: string): Callout {
223
+ return new Callout(content);
224
+ }
225
+
226
+ export type PrimeKind = "text" | "html" | "image";
227
+
228
+ /** A static "prime" display component in a schema — text, raw HTML, or an image. */
229
+ export class Prime {
230
+ /** @internal */ _kind: PrimeKind;
231
+ /** @internal */ _content: string;
232
+ /** @internal */ _alt?: string;
233
+ constructor(kind: PrimeKind, content: string) {
234
+ this._kind = kind;
235
+ this._content = content;
236
+ }
237
+ alt(alt: string): this {
238
+ this._alt = alt;
239
+ return this;
240
+ }
241
+ }
242
+ export function prime(text: string): Prime {
243
+ return new Prime("text", text);
244
+ }
245
+ export function primeHtml(html: string): Prime {
246
+ return new Prime("html", html);
247
+ }
248
+ export function primeImage(src: string): Prime {
249
+ return new Prime("image", src);
250
+ }
251
+
252
+ /** Side-by-side sections. */
253
+ export class FormSplit {
254
+ /** @internal */ _sections: FormSection[];
255
+ constructor(sections: FormSection[]) {
256
+ this._sections = sections;
257
+ }
258
+ getFields(): Field[] {
259
+ return this._sections.flatMap((s) => s.getFields());
260
+ }
261
+ }
262
+ export function split(sections: FormSection[]): FormSplit {
263
+ return new FormSplit(sections);
264
+ }
265
+
266
+ /** A form is an ordered list of layout components and/or loose fields. */
267
+ export type FormComponent = FormSection | FormTabs | Wizard | FormSplit | Callout | Prime | Field;
268
+
269
+ /** A titled, multi-column block of form fields. */
270
+ export function formSection(heading?: string): FormSection {
271
+ return new FormSection(heading);
272
+ }
273
+
274
+ export function isFormSection(c: FormComponent): c is FormSection {
275
+ return c instanceof FormSection;
276
+ }
277
+
278
+ /** Collect every field across sections, tab groups, + loose fields, in order. */
279
+ export function flattenFields(components: FormComponent[]): Field[] {
280
+ const out: Field[] = [];
281
+ for (const c of components) {
282
+ if (
283
+ c instanceof FormSection ||
284
+ c instanceof FormTabs ||
285
+ c instanceof Wizard ||
286
+ c instanceof FormSplit
287
+ ) {
288
+ out.push(...c.getFields());
289
+ } else if (c instanceof Callout || c instanceof Prime) {
290
+ // Display-only — no fields.
291
+ } else {
292
+ out.push(c);
293
+ }
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /** Normalize components into sections, wrapping loose fields in a default section. */
299
+ export function toFormSections(components: FormComponent[]): FormSection[] {
300
+ const sections: FormSection[] = [];
301
+ let loose: Field[] = [];
302
+ const flush = (): void => {
303
+ if (loose.length) {
304
+ sections.push(new FormSection().columns(2).schema(loose));
305
+ loose = [];
306
+ }
307
+ };
308
+ for (const c of components) {
309
+ if (c instanceof FormSection) {
310
+ flush();
311
+ sections.push(c);
312
+ } else if (c instanceof Field) {
313
+ loose.push(c);
314
+ }
315
+ }
316
+ flush();
317
+ return sections;
318
+ }
319
+
320
+ /** A renderable layout block. */
321
+ export type FormBlock =
322
+ | { kind: "section"; section: FormSection }
323
+ | { kind: "tabs"; tabs: FormTabs }
324
+ | { kind: "wizard"; wizard: Wizard }
325
+ | { kind: "split"; split: FormSplit }
326
+ | { kind: "callout"; callout: Callout }
327
+ | { kind: "prime"; prime: Prime };
328
+
329
+ /** Normalize the schema into ordered layout blocks (loose fields → a section). */
330
+ export function toFormLayout(components: FormComponent[]): FormBlock[] {
331
+ const blocks: FormBlock[] = [];
332
+ let loose: Field[] = [];
333
+ const flush = (): void => {
334
+ if (loose.length) {
335
+ blocks.push({ kind: "section", section: new FormSection().columns(2).schema(loose) });
336
+ loose = [];
337
+ }
338
+ };
339
+ for (const c of components) {
340
+ if (c instanceof FormTabs) {
341
+ flush();
342
+ blocks.push({ kind: "tabs", tabs: c });
343
+ } else if (c instanceof Wizard) {
344
+ flush();
345
+ blocks.push({ kind: "wizard", wizard: c });
346
+ } else if (c instanceof FormSplit) {
347
+ flush();
348
+ blocks.push({ kind: "split", split: c });
349
+ } else if (c instanceof Callout) {
350
+ flush();
351
+ blocks.push({ kind: "callout", callout: c });
352
+ } else if (c instanceof Prime) {
353
+ flush();
354
+ blocks.push({ kind: "prime", prime: c });
355
+ } else if (c instanceof FormSection) {
356
+ flush();
357
+ blocks.push({ kind: "section", section: c });
358
+ } else {
359
+ loose.push(c);
360
+ }
361
+ }
362
+ flush();
363
+ return blocks;
364
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tiny client helpers for the editor field types, injected once per form page
3
+ * (guarded so it defines its globals only once). They are intentionally
4
+ * dependency-free — no CDN editor — so they survive Flow's DOM morph:
5
+ *
6
+ * - `__kTab` — tab-to-indent inside the code textarea.
7
+ * - `__kMd` — wrap the markdown textarea selection (bold/italic/link…).
8
+ * - `__kRich` — bind a contenteditable to a hidden, Flow-modeled textarea
9
+ * (writes innerHTML back + dispatches `input` so the server syncs).
10
+ * - `__kRichCmd` — `document.execCommand` for the rich toolbar.
11
+ *
12
+ * Each writes to the bound field and dispatches a bubbling `input` event, which is
13
+ * exactly what Flow's `flow:model` listener consumes — so editor edits round-trip
14
+ * to the server like any other field.
15
+ */
16
+ export const EDITOR_SCRIPT = `
17
+ (function(){
18
+ if (window.__kEditors) return; window.__kEditors = 1;
19
+ function sync(el){ el.dispatchEvent(new Event('input', { bubbles: true })); }
20
+ window.__kTab = function(ev, id){
21
+ if (ev.key !== 'Tab') return; ev.preventDefault();
22
+ var ta = document.getElementById(id); if (!ta) return;
23
+ var s = ta.selectionStart, e = ta.selectionEnd;
24
+ ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(e);
25
+ ta.selectionStart = ta.selectionEnd = s + 2; sync(ta);
26
+ };
27
+ window.__kMd = function(id, before, after){
28
+ var ta = document.getElementById(id); if (!ta) return;
29
+ var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
30
+ ta.value = v.slice(0, s) + before + v.slice(s, e) + after + v.slice(e);
31
+ sync(ta); ta.focus();
32
+ ta.selectionStart = s + before.length; ta.selectionEnd = e + before.length;
33
+ };
34
+ window.__kRich = function(edId, hId){
35
+ var ed = document.getElementById(edId), h = document.getElementById(hId);
36
+ if (!ed || !h || ed.__k) return; ed.__k = 1;
37
+ if (document.activeElement !== ed) ed.innerHTML = h.value || '';
38
+ ed.addEventListener('input', function(){ h.value = ed.innerHTML; sync(h); });
39
+ ed.addEventListener('blur', function(){ h.value = ed.innerHTML; sync(h); });
40
+ };
41
+ window.__kRichCmd = function(cmd){ try { document.execCommand(cmd, false, null); } catch (e) {} };
42
+ })();
43
+ `.trim();
@@ -0,0 +1,59 @@
1
+ /** Form building blocks — editable schemas for the Create and Edit pages. */
2
+ export {
3
+ Field,
4
+ textInput,
5
+ textarea,
6
+ select,
7
+ checkbox,
8
+ toggle,
9
+ radio,
10
+ checkboxList,
11
+ datePicker,
12
+ dateTimePicker,
13
+ timePicker,
14
+ colorPicker,
15
+ hidden,
16
+ tagsInput,
17
+ keyValue,
18
+ fileUpload,
19
+ mediaPicker,
20
+ slider,
21
+ toggleButtons,
22
+ codeEditor,
23
+ markdownEditor,
24
+ richEditor,
25
+ repeater,
26
+ builder,
27
+ customField,
28
+ BuilderBlock,
29
+ builderBlock,
30
+ } from "./Field.ts";
31
+ export type { FieldType, FieldMode, SelectOption, FieldPredicate } from "./Field.ts";
32
+ export {
33
+ FormSection,
34
+ formSection,
35
+ isFormSection,
36
+ flattenFields,
37
+ toFormSections,
38
+ toFormLayout,
39
+ FormTab,
40
+ FormTabs,
41
+ formTab,
42
+ formTabs,
43
+ WizardStep,
44
+ Wizard,
45
+ wizardStep,
46
+ wizard,
47
+ fieldset,
48
+ Callout,
49
+ callout,
50
+ Prime,
51
+ prime,
52
+ primeHtml,
53
+ primeImage,
54
+ FormSplit,
55
+ split,
56
+ } from "./Section.ts";
57
+ export type { FormComponent, FormBlock, CalloutTone, PrimeKind } from "./Section.ts";
58
+ export { makeResourceForm } from "./ResourceForm.ts";
59
+ export type { ResourceFormClass } from "./ResourceForm.ts";
package/src/history.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Record history — who changed what, and putting it back.
3
+ *
4
+ * `@zerotal/audit` already records every create, update and delete on a model
5
+ * that composes `Auditable`. This turns that into something a person can read on
6
+ * the record's own page, and — where the change was an update — undo:
7
+ *
8
+ * export class OrderResource extends Resource {
9
+ * static override history = true;
10
+ * }
11
+ *
12
+ * The audit package is resolved lazily, so it stays an optional peer: a resource
13
+ * that asks for history without it installed simply shows nothing.
14
+ */
15
+ import { frameworkLog } from "@zerotal/core/logger";
16
+
17
+ /** One entry in a record's history, already shaped for display. */
18
+ export interface HistoryEntry {
19
+ id: string;
20
+ /** `created`, `updated`, `deleted`, `restored`. */
21
+ event: string;
22
+ /** Who did it, resolved to a name where possible. */
23
+ actor: string | null;
24
+ /** When, as an ISO string. */
25
+ at: string;
26
+ /** Field-by-field before/after, for an update. */
27
+ changes: HistoryChange[];
28
+ /** True when this entry can be put back — an update with recorded old values. */
29
+ revertible: boolean;
30
+ }
31
+
32
+ export interface HistoryChange {
33
+ field: string;
34
+ from: unknown;
35
+ to: unknown;
36
+ }
37
+
38
+ /** The stored audit row, as `@zerotal/audit` writes it. */
39
+ interface AuditRow {
40
+ id: unknown;
41
+ event: string;
42
+ actorId: number | null;
43
+ oldValues: Record<string, unknown> | null;
44
+ newValues: Record<string, unknown> | null;
45
+ createdAt: unknown;
46
+ }
47
+
48
+ export interface HistoryOptions {
49
+ /** Model name the audit rows are filed under — defaults to the resource's model. */
50
+ type: string;
51
+ id: unknown;
52
+ /** How many entries to show. Defaults to 25 — a record page is not an archive. */
53
+ limit?: number;
54
+ /** Turn an actor id into a name. Defaults to `#<id>`. */
55
+ resolveActor?: (id: number) => Promise<string | null> | string | null;
56
+ }
57
+
58
+ /** Fields that change on every write and say nothing about intent. */
59
+ const NOISE = new Set(["updated_at", "updatedAt", "created_at", "createdAt"]);
60
+
61
+ /**
62
+ * Read a record's history, newest first.
63
+ *
64
+ * Returns an empty list rather than throwing when the audit package isn't
65
+ * installed or its table doesn't exist yet — a missing history is a missing
66
+ * section, not a broken page.
67
+ */
68
+ export async function recordHistory(options: HistoryOptions): Promise<HistoryEntry[]> {
69
+ const limit = options.limit ?? 25;
70
+ try {
71
+ const mod = (await import(/* @vite-ignore */ "@zerotal/audit" as string)) as {
72
+ AuditLog?: {
73
+ forModel?: (
74
+ type: string,
75
+ id: unknown,
76
+ ) => {
77
+ orderBy: (
78
+ c: string,
79
+ d: string,
80
+ ) => { limit: (n: number) => { get: () => Promise<AuditRow[]> } };
81
+ };
82
+ };
83
+ };
84
+ const model = mod.AuditLog;
85
+ if (!model?.forModel) return [];
86
+
87
+ const rows = await model
88
+ .forModel(options.type, options.id)
89
+ .orderBy("created_at", "desc")
90
+ .limit(limit)
91
+ .get();
92
+
93
+ return Promise.all(rows.map((row) => toEntry(row, options.resolveActor)));
94
+ } catch (error) {
95
+ frameworkLog("admin").warn("Record history unavailable", undefined, error);
96
+ return [];
97
+ }
98
+ }
99
+
100
+ async function toEntry(
101
+ row: AuditRow,
102
+ resolveActor?: HistoryOptions["resolveActor"],
103
+ ): Promise<HistoryEntry> {
104
+ const changes = diff(row.oldValues, row.newValues);
105
+ let actor: string | null = null;
106
+ if (row.actorId != null) {
107
+ actor = (await resolveActor?.(row.actorId)) ?? `#${row.actorId}`;
108
+ }
109
+
110
+ return {
111
+ id: String(row.id),
112
+ event: row.event,
113
+ actor,
114
+ at: stringifyDate(row.createdAt),
115
+ changes,
116
+ // Only an update can be put back: a create has nothing to restore to, and a
117
+ // delete is the restore action's job rather than history's.
118
+ revertible: row.event === "updated" && changes.length > 0,
119
+ };
120
+ }
121
+
122
+ /** Field-by-field difference, ignoring the columns that always move. */
123
+ function diff(
124
+ before: Record<string, unknown> | null,
125
+ after: Record<string, unknown> | null,
126
+ ): HistoryChange[] {
127
+ const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
128
+ const changes: HistoryChange[] = [];
129
+ for (const field of keys) {
130
+ if (NOISE.has(field)) continue;
131
+ const from = before?.[field];
132
+ const to = after?.[field];
133
+ if (JSON.stringify(from) === JSON.stringify(to)) continue;
134
+ changes.push({ field, from, to });
135
+ }
136
+ return changes;
137
+ }
138
+
139
+ /** The values that would put a record back to how it was before an entry. */
140
+ export function revertPayload(entry: HistoryEntry): Record<string, unknown> {
141
+ const payload: Record<string, unknown> = {};
142
+ for (const change of entry.changes) payload[change.field] = change.from;
143
+ return payload;
144
+ }
145
+
146
+ function stringifyDate(value: unknown): string {
147
+ if (value instanceof Date) return value.toISOString();
148
+ const withIso = value as { toISOString?: () => string } | null | undefined;
149
+ if (typeof withIso?.toISOString === "function") return withIso.toISOString();
150
+ return String(value ?? "");
151
+ }