@zeyos/zx 4.1.0 → 4.2.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 (83) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/DESIGN-SYSTEM.md +33 -3
  3. package/README.md +17 -5
  4. package/dist/types/components/app-icon/app-icon.d.ts +8 -1
  5. package/dist/types/components/aurora/aurora.d.ts +72 -0
  6. package/dist/types/components/calendar/calendar-model.d.ts +202 -0
  7. package/dist/types/components/calendar/calendar.d.ts +500 -0
  8. package/dist/types/components/card-view/card-view.d.ts +303 -0
  9. package/dist/types/components/card-view/record-card.d.ts +242 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
  11. package/dist/types/components/table/table.d.ts +120 -31
  12. package/dist/types/components/table-view/table-view.d.ts +192 -0
  13. package/dist/types/components/view/record-view.d.ts +464 -0
  14. package/dist/types/index.d.ts +7 -0
  15. package/dist/types/zeyos/calendar.d.ts +91 -0
  16. package/dist/types/zeyos/icons.d.ts +19 -3
  17. package/dist/types/zeyos/index.d.ts +4 -0
  18. package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
  19. package/dist/types/zeyos/saved-views.d.ts +331 -0
  20. package/dist/types/zeyos/view.d.ts +305 -0
  21. package/dist/zx-zeyos.esm.js +4627 -1460
  22. package/dist/zx-zeyos.esm.min.js +1 -1
  23. package/dist/zx.css +2492 -410
  24. package/dist/zx.esm.js +4891 -115
  25. package/dist/zx.esm.min.js +3 -3
  26. package/dist/zx.global.js +4891 -115
  27. package/dist/zx.global.min.js +3 -3
  28. package/dist/zx.min.css +1 -1
  29. package/docs/api.json +1702 -191
  30. package/docs/llms.md +207 -20
  31. package/docs/llms.txt +16 -6
  32. package/package.json +1 -1
  33. package/src/components/app-icon/app-icon.css +140 -30
  34. package/src/components/app-icon/app-icon.js +32 -5
  35. package/src/components/app-rail/app-rail.css +11 -2
  36. package/src/components/app-sidebar/app-sidebar.css +11 -2
  37. package/src/components/aurora/aurora.css +143 -0
  38. package/src/components/aurora/aurora.js +146 -0
  39. package/src/components/avatar/avatar.css +1 -1
  40. package/src/components/button/button.css +34 -2
  41. package/src/components/calendar/calendar-model.js +402 -0
  42. package/src/components/calendar/calendar.css +567 -0
  43. package/src/components/calendar/calendar.js +1524 -0
  44. package/src/components/card/card.css +25 -3
  45. package/src/components/card-view/card-view.css +389 -0
  46. package/src/components/card-view/card-view.js +527 -0
  47. package/src/components/card-view/record-card.js +392 -0
  48. package/src/components/context-menu/context-menu.css +2 -2
  49. package/src/components/date-picker/date-picker.css +15 -3
  50. package/src/components/datebox/date-range-box.css +15 -2
  51. package/src/components/datebox/datebox.css +16 -3
  52. package/src/components/dropdown/dropdown.css +15 -3
  53. package/src/components/kanban-view/kanban-view.css +216 -0
  54. package/src/components/kanban-view/kanban-view.js +1228 -0
  55. package/src/components/launcher/launcher.css +18 -5
  56. package/src/components/master-panel/master-panel.css +14 -3
  57. package/src/components/menu-button/menu-button.css +2 -2
  58. package/src/components/message/message.css +15 -10
  59. package/src/components/modal/modal.css +15 -3
  60. package/src/components/navigation-bar/navigation-bar.css +17 -5
  61. package/src/components/panel/panel.css +10 -2
  62. package/src/components/select/select.css +20 -4
  63. package/src/components/sheet/sheet.css +4 -1
  64. package/src/components/table/table.css +80 -4
  65. package/src/components/table/table.js +322 -37
  66. package/src/components/table-view/table-view.css +18 -0
  67. package/src/components/table-view/table-view.js +347 -0
  68. package/src/components/tag-picker/tag-picker.css +15 -3
  69. package/src/components/tooltip/tooltip.css +15 -3
  70. package/src/components/view/record-view.css +114 -0
  71. package/src/components/view/record-view.js +770 -0
  72. package/src/index.js +15 -0
  73. package/src/zeyos/calendar.js +115 -0
  74. package/src/zeyos/icons.js +15 -8
  75. package/src/zeyos/index.js +13 -0
  76. package/src/zeyos/launcher.js +2 -2
  77. package/src/zeyos/legacy-saved-views.js +155 -0
  78. package/src/zeyos/saved-views.js +815 -0
  79. package/src/zeyos/table.js +3 -1
  80. package/src/zeyos/view.js +543 -0
  81. package/styles/icons.css +3 -10
  82. package/styles/tokens/semantic.css +74 -2
  83. package/styles/zx.css +6 -0
@@ -0,0 +1,815 @@
1
+ // @ts-check
2
+
3
+ /** Current persisted saved-view document version. */
4
+ export const SAVED_VIEWS_VERSION = 1;
5
+
6
+ /** @typedef {'table'|'card'|'kanban'} SavedViewType */
7
+ /** @typedef {'reject'|'replace'} SavedViewDuplicatePolicy */
8
+
9
+ /**
10
+ * @typedef {Object} SavedViewScope
11
+ * @property {string} userId Authenticated user identifier.
12
+ * @property {string|null} workspaceId Workspace/fork identifier; null identifies the base workspace.
13
+ * @property {string} resource ZeyOS resource/view identifier.
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} SavedViewEntry
18
+ * @property {string} id Stable saved-view identifier.
19
+ * @property {string} name User-facing unique name.
20
+ * @property {SavedViewType} type Presentation type.
21
+ * @property {Record<string,unknown>} state Versioned RecordView configuration.
22
+ * @property {Record<string,unknown>} filters JSON-safe query/filter configuration.
23
+ * @property {string} search Full-text query.
24
+ * @property {string} createdAt ISO-8601 creation timestamp.
25
+ * @property {string} updatedAt ISO-8601 update timestamp.
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} SavedViewDocument
30
+ * @property {1} version Persisted document schema version.
31
+ * @property {SavedViewScope} scope Scope duplicated in the document to detect transport mix-ups.
32
+ * @property {string|null} defaultId Default saved-view id.
33
+ * @property {SavedViewEntry[]} views Deterministically ordered saved views.
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} SavedViewTransport
38
+ * @property {(scope:SavedViewScope)=>unknown|Promise<unknown>} load Loads the one document for scope.
39
+ * @property {(scope:SavedViewScope,document:SavedViewDocument)=>unknown|Promise<unknown>} save
40
+ * Atomically replaces the one document for scope.
41
+ * @property {(scope:SavedViewScope)=>unknown|Promise<unknown>} [remove] Removes the scope document.
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} SavedViewRegistryOptions
46
+ * @property {SavedViewDuplicatePolicy} [duplicateNames='reject'] Duplicate-name policy.
47
+ * @property {()=>string|Date|number} [clock] Timestamp source; defaults to the current time.
48
+ * @property {(context:{scope:SavedViewScope,name:string,type:SavedViewType,createdAt:string})=>string} [idFactory]
49
+ * Stable-id factory. A deterministic dependency-free id is used when omitted.
50
+ * @property {Record<number,(document:Record<string,unknown>)=>Record<string,unknown>>} [migrations]
51
+ * Optional migrations keyed by their source version. Version 0 has a built-in migration.
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} SavedViewInput
56
+ * @property {string} [id] Stable id for an update or import.
57
+ * @property {string} name Unique display name.
58
+ * @property {SavedViewType} [type] Presentation type; required for a new view.
59
+ * @property {Record<string,unknown>} [state] RecordView state; required for a new view.
60
+ * @property {Record<string,unknown>} [filters] Query/filter state.
61
+ * @property {string} [search=''] Full-text query.
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} SavedViewApplyResult
66
+ * @property {'applied'|'missing'|'type-mismatch'} status Result status.
67
+ * @property {boolean} applied Whether state was applied.
68
+ * @property {SavedViewEntry|null} entry Resolved saved entry.
69
+ * @property {SavedViewType} [expectedType] Saved presentation required by the entry on a mismatch.
70
+ * @property {SavedViewType} [currentType] Current/requested renderer presentation on a mismatch.
71
+ */
72
+
73
+ const TYPES = new Set(['table', 'card', 'kanban']);
74
+ const EPOCH = '1970-01-01T00:00:00.000Z';
75
+ const FORBIDDEN_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
76
+ const FORBIDDEN_STATE_KEYS = new Set([
77
+ 'cursor', 'data', 'offset', 'page', 'pagination', 'records', 'rows', 'selection', 'selectedRecords'
78
+ ]);
79
+
80
+ /** Duplicate saved-view name. */
81
+ export class SavedViewDuplicateNameError extends Error {
82
+ /** @param {string} name Duplicate display name. */
83
+ constructor(name) {
84
+ super(`A saved view named "${name}" already exists`);
85
+ this.name = 'SavedViewDuplicateNameError';
86
+ }
87
+ }
88
+
89
+ /** A transport returned a document belonging to another scope. */
90
+ export class SavedViewScopeMismatchError extends Error {
91
+ /** @param {SavedViewScope} expected Expected scope. @param {SavedViewScope} actual Stored scope. */
92
+ constructor(expected, actual) {
93
+ super(`Saved-view scope mismatch: expected ${scopeKey(expected)}, received ${scopeKey(actual)}`);
94
+ this.name = 'SavedViewScopeMismatchError';
95
+ this.expected = { ...expected };
96
+ this.actual = { ...actual };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Dependency-free named saved-view registry with an injected, scope-atomic transport.
102
+ */
103
+ export class SavedViewRegistry {
104
+ /**
105
+ * @param {SavedViewTransport} transport Persistence adapter.
106
+ * @param {SavedViewScope} scope User/workspace/resource scope.
107
+ * @param {SavedViewRegistryOptions} [options={}] Registry policy and deterministic test hooks.
108
+ */
109
+ constructor(transport, scope, options = {}) {
110
+ if (!transport || typeof transport.load !== 'function' || typeof transport.save !== 'function') {
111
+ throw new TypeError('SavedViewRegistry transport requires load() and save()');
112
+ }
113
+ this.transport = transport;
114
+ this.scope = normalizeSavedViewScope(scope);
115
+ this.options = normalizeRegistryOptions(options);
116
+ this._document = emptySavedViewDocument(this.scope);
117
+ this._loaded = false;
118
+ this._loading = null;
119
+ this._loadSequence = 0;
120
+ this._mutation = Promise.resolve();
121
+ this._destroyed = false;
122
+ }
123
+
124
+ /** Loads the scope once and returns this registry. @returns {Promise<this>} */
125
+ async ready() {
126
+ return this.load();
127
+ }
128
+
129
+ /**
130
+ * Loads and normalizes the persisted document. Repeated calls use the in-memory snapshot unless
131
+ * `force` is true.
132
+ * @param {{force?:boolean}} [options={}] Load behavior.
133
+ * @returns {Promise<this>}
134
+ */
135
+ async load(options = {}) {
136
+ this._assertActive();
137
+ if (this._loaded && !options.force) return this;
138
+ if (this._loading && !options.force) {
139
+ await this._loading;
140
+ return this;
141
+ }
142
+ const sequence = ++this._loadSequence;
143
+ const load = Promise.resolve(this.transport.load({ ...this.scope })).then((value) => {
144
+ this._assertActive();
145
+ if (sequence !== this._loadSequence) return;
146
+ const document = normalizeSavedViewDocument(value, this.scope, this.options);
147
+ this._document = document;
148
+ this._loaded = true;
149
+ });
150
+ this._loading = load;
151
+ try {
152
+ await load;
153
+ } finally {
154
+ if (this._loading === load) this._loading = null;
155
+ }
156
+ return this;
157
+ }
158
+
159
+ /** Returns all entries in deterministic name/id order. @returns {Promise<SavedViewEntry[]>} */
160
+ async list() {
161
+ await this.ready();
162
+ return this._document.views.map(cloneSavedViewEntry);
163
+ }
164
+
165
+ /** Resolves an entry by exact id or normalized name. @param {string} idOrName @returns {Promise<SavedViewEntry|null>} */
166
+ async get(idOrName) {
167
+ await this.ready();
168
+ const entry = resolveEntry(this._document, idOrName);
169
+ return entry ? cloneSavedViewEntry(entry) : null;
170
+ }
171
+
172
+ /** Returns the default entry, or null. @returns {Promise<SavedViewEntry|null>} */
173
+ async getDefault() {
174
+ await this.ready();
175
+ const entry = this._document.views.find((view) => view.id === this._document.defaultId) ?? null;
176
+ return entry ? cloneSavedViewEntry(entry) : null;
177
+ }
178
+
179
+ /**
180
+ * Creates or updates one named entry and persists the whole scope in one transport write.
181
+ * @param {SavedViewInput} input Saved-view values.
182
+ * @param {{onDuplicate?:SavedViewDuplicatePolicy,setDefault?:boolean}} [options={}] Write policy.
183
+ * @returns {Promise<SavedViewEntry>} Saved entry.
184
+ */
185
+ async save(input, options = {}) {
186
+ return this._mutate(async (document) => {
187
+ const name = normalizeName(input?.name);
188
+ const policy = normalizeDuplicatePolicy(options.onDuplicate ?? this.options.duplicateNames);
189
+ const requestedId = input?.id == null ? null : normalizeId(input.id);
190
+ const byId = requestedId ? document.views.find((entry) => entry.id === requestedId) ?? null : null;
191
+ const byName = document.views.find((entry) => nameKey(entry.name) === nameKey(name)) ?? null;
192
+ if (byId && byName && byId !== byName && policy === 'reject') {
193
+ throw new SavedViewDuplicateNameError(name);
194
+ }
195
+ if (!byId && byName && policy === 'reject') throw new SavedViewDuplicateNameError(name);
196
+ const existing = byId ?? (policy === 'replace' ? byName : null);
197
+ const replaced = policy === 'replace' && byId && byName !== byId ? byName : null;
198
+ const now = this._now();
199
+ const type = normalizeType(input?.type ?? existing?.type);
200
+ const state = Object.prototype.hasOwnProperty.call(input ?? {}, 'state')
201
+ ? normalizeRecordViewState(input.state) : existing ? cloneJsonObject(existing.state, 'Saved view state') : null;
202
+ if (!state) throw new TypeError('A new saved view requires state');
203
+ const filters = Object.prototype.hasOwnProperty.call(input ?? {}, 'filters')
204
+ ? normalizeFilters(input.filters) : existing ? cloneJsonObject(existing.filters, 'Saved view filters') : {};
205
+ const search = Object.prototype.hasOwnProperty.call(input ?? {}, 'search')
206
+ ? normalizeSearch(input.search) : existing?.search ?? '';
207
+ const createdAt = existing?.createdAt ?? now;
208
+ const id = existing?.id ?? requestedId ?? this._createId(document, { name, type, createdAt });
209
+ const entry = { id, name, type, state, filters, search, createdAt, updatedAt: now };
210
+ const views = document.views.filter((candidate) => candidate !== existing
211
+ && candidate !== replaced && candidate.id !== id);
212
+ views.push(entry);
213
+ document.views = sortEntries(views);
214
+ if (replaced && document.defaultId === replaced.id) document.defaultId = id;
215
+ if (options.setDefault) document.defaultId = id;
216
+ return { changed: true, result: entry };
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Renames an entry without changing its stable id or creation time.
222
+ * @param {string} idOrName Entry id or name.
223
+ * @param {string} name New display name.
224
+ * @param {{onDuplicate?:SavedViewDuplicatePolicy}} [options={}] Duplicate policy.
225
+ * @returns {Promise<SavedViewEntry|null>} Updated entry, or null when absent.
226
+ */
227
+ async rename(idOrName, name, options = {}) {
228
+ return this._mutate(async (document) => {
229
+ const entry = resolveEntry(document, idOrName);
230
+ if (!entry) return { changed: false, result: null };
231
+ const nextName = normalizeName(name);
232
+ if (entry.name === nextName) return { changed: false, result: entry };
233
+ const duplicate = document.views.find((candidate) => candidate !== entry
234
+ && nameKey(candidate.name) === nameKey(nextName)) ?? null;
235
+ const policy = normalizeDuplicatePolicy(options.onDuplicate ?? this.options.duplicateNames);
236
+ if (duplicate && policy === 'reject') throw new SavedViewDuplicateNameError(nextName);
237
+ if (duplicate) {
238
+ document.views = document.views.filter((candidate) => candidate !== duplicate);
239
+ if (document.defaultId === duplicate.id) document.defaultId = entry.id;
240
+ }
241
+ entry.name = nextName;
242
+ entry.updatedAt = this._now();
243
+ document.views = sortEntries(document.views);
244
+ return { changed: true, result: entry };
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Removes one named entry. The optional transport `remove(scope)` is used when the document
250
+ * becomes empty; otherwise the empty document is saved normally.
251
+ * @param {string} idOrName Entry id or name.
252
+ * @returns {Promise<boolean>} Whether an entry was removed.
253
+ */
254
+ async remove(idOrName) {
255
+ return this._mutate(async (document) => {
256
+ const entry = resolveEntry(document, idOrName);
257
+ if (!entry) return { changed: false, result: false };
258
+ document.views = document.views.filter((candidate) => candidate !== entry);
259
+ if (document.defaultId === entry.id) document.defaultId = null;
260
+ return { changed: true, removeDocument: document.views.length === 0, result: true };
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Sets or clears the default view.
266
+ * @param {string|null} idOrName Entry id/name or null.
267
+ * @returns {Promise<SavedViewEntry|null>} New default, or null when cleared/absent.
268
+ */
269
+ async setDefault(idOrName) {
270
+ return this._mutate(async (document) => {
271
+ if (idOrName == null) {
272
+ if (document.defaultId === null) return { changed: false, result: null };
273
+ document.defaultId = null;
274
+ return { changed: true, result: null };
275
+ }
276
+ const entry = resolveEntry(document, idOrName);
277
+ if (!entry) return { changed: false, result: null };
278
+ if (document.defaultId === entry.id) return { changed: false, result: entry };
279
+ document.defaultId = entry.id;
280
+ return { changed: true, result: entry };
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Captures `view.getViewState()`, persists it through `save()`, and returns the saved entry.
286
+ * Records and selection are never read from the view.
287
+ * @param {{getViewState:()=>Record<string,unknown>}} view RecordView-compatible instance.
288
+ * @param {Omit<SavedViewInput,'state'>} input Entry metadata/query state.
289
+ * @param {{onDuplicate?:SavedViewDuplicatePolicy,setDefault?:boolean}} [options={}] Write policy.
290
+ * @returns {Promise<SavedViewEntry>} Captured entry.
291
+ */
292
+ async capture(view, input, options = {}) {
293
+ if (!view || typeof view.getViewState !== 'function') {
294
+ throw new TypeError('capture() requires a view with getViewState()');
295
+ }
296
+ return this.save({ ...input, state: view.getViewState() }, options);
297
+ }
298
+
299
+ /**
300
+ * Applies one entry to a RecordView-compatible target. An expected type mismatch is reported
301
+ * without touching the target. Query state is handed to one optional combined callback so a
302
+ * server-backed binding can perform one reload rather than separate filter/search reloads.
303
+ * @param {string} idOrName Entry id or name.
304
+ * @param {{setViewState:(state:Record<string,unknown>)=>unknown}} view RecordView target.
305
+ * @param {{type?:SavedViewType,onQuery?:(query:{filters:Record<string,unknown>,search:string,entry:SavedViewEntry})=>unknown|Promise<unknown>}} [options={}] Apply behavior.
306
+ * @returns {Promise<SavedViewApplyResult>} Explicit apply result.
307
+ */
308
+ async apply(idOrName, view, options = {}) {
309
+ const entry = await this.get(idOrName);
310
+ if (!entry) return { status: 'missing', applied: false, entry: null };
311
+ if (options.type != null) {
312
+ const currentType = normalizeType(options.type);
313
+ if (entry.type !== currentType) {
314
+ return {
315
+ status: 'type-mismatch', applied: false, entry,
316
+ expectedType: entry.type, currentType
317
+ };
318
+ }
319
+ }
320
+ if (!view || typeof view.setViewState !== 'function') {
321
+ throw new TypeError('apply() requires a view with setViewState()');
322
+ }
323
+ if (options.onQuery != null && typeof options.onQuery !== 'function') {
324
+ throw new TypeError('apply() onQuery must be a function');
325
+ }
326
+ view.setViewState(cloneJsonObject(entry.state, 'Saved view state'));
327
+ if (options.onQuery) {
328
+ await options.onQuery({
329
+ filters: cloneJsonObject(entry.filters, 'Saved view filters'),
330
+ search: entry.search,
331
+ entry: cloneSavedViewEntry(entry)
332
+ });
333
+ }
334
+ return { status: 'applied', applied: true, entry };
335
+ }
336
+
337
+ /** Prevents later operations; it never removes persisted user data. @returns {void} */
338
+ destroy() {
339
+ this._destroyed = true;
340
+ this._loadSequence += 1;
341
+ this._loading = null;
342
+ }
343
+
344
+ /** @private @returns {void} */
345
+ _assertActive() {
346
+ if (this._destroyed) throw new Error('SavedViewRegistry has been destroyed');
347
+ }
348
+
349
+ /** @private @returns {string} */
350
+ _now() {
351
+ const source = this.options.clock();
352
+ const date = source instanceof Date ? source : new Date(source);
353
+ if (!Number.isFinite(date.getTime())) throw new TypeError('Saved-view clock returned an invalid timestamp');
354
+ return date.toISOString();
355
+ }
356
+
357
+ /**
358
+ * @private
359
+ * @param {SavedViewDocument} document
360
+ * @param {{name:string,type:SavedViewType,createdAt:string}} values
361
+ * @returns {string}
362
+ */
363
+ _createId(document, values) {
364
+ const proposed = this.options.idFactory
365
+ ? this.options.idFactory({ scope: { ...this.scope }, ...values })
366
+ : `view-${hashString(`${scopeKey(this.scope)}\u0000${values.name}\u0000${values.type}\u0000${values.createdAt}`)}`;
367
+ const base = normalizeId(proposed);
368
+ const ids = new Set(document.views.map((entry) => entry.id));
369
+ if (!ids.has(base)) return base;
370
+ let suffix = 2;
371
+ while (ids.has(`${base}-${suffix}`)) suffix += 1;
372
+ return `${base}-${suffix}`;
373
+ }
374
+
375
+ /**
376
+ * Serializes mutations inside one registry so concurrent calls cannot overwrite each other.
377
+ * @private
378
+ * @template T
379
+ * @param {(document:SavedViewDocument)=>Promise<{changed:boolean,result:T,removeDocument?:boolean}>} mutate
380
+ * @returns {Promise<T>}
381
+ */
382
+ _mutate(mutate) {
383
+ this._assertActive();
384
+ const run = this._mutation.catch(() => {}).then(async () => {
385
+ await this.ready();
386
+ const next = cloneSavedViewDocument(this._document);
387
+ const mutation = await mutate(next);
388
+ this._assertActive();
389
+ if (!mutation.changed) return cloneResult(mutation.result);
390
+ const normalized = normalizeSavedViewDocument(next, this.scope, this.options);
391
+ // A write is authoritative over any transport read that was already in flight.
392
+ this._loadSequence += 1;
393
+ if (mutation.removeDocument && typeof this.transport.remove === 'function') {
394
+ await this.transport.remove({ ...this.scope });
395
+ this._assertActive();
396
+ this._loadSequence += 1;
397
+ this._document = emptySavedViewDocument(this.scope);
398
+ } else {
399
+ await this.transport.save({ ...this.scope }, cloneSavedViewDocument(normalized));
400
+ this._assertActive();
401
+ this._loadSequence += 1;
402
+ this._document = normalized;
403
+ }
404
+ return cloneResult(mutation.result);
405
+ });
406
+ this._mutation = run.then(() => undefined, () => undefined);
407
+ return run;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Factory useful to adapters that do not need to subclass the registry.
413
+ * @param {SavedViewTransport} transport Persistence adapter.
414
+ * @param {SavedViewScope} scope Scope.
415
+ * @param {SavedViewRegistryOptions} [options={}] Options.
416
+ * @returns {SavedViewRegistry}
417
+ */
418
+ export function createSavedViewRegistry(transport, scope, options = {}) {
419
+ return new SavedViewRegistry(transport, scope, options);
420
+ }
421
+
422
+ /**
423
+ * Validates and canonicalizes a transport scope. String/number identifiers are intentionally
424
+ * accepted so a ZeyOS fork id can bridge directly to `workspaceId`; null is the base fork.
425
+ * @param {SavedViewScope|Record<string,unknown>} scope Scope.
426
+ * @returns {SavedViewScope} Normalized scope.
427
+ */
428
+ export function normalizeSavedViewScope(scope) {
429
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
430
+ throw new TypeError('Saved-view scope must be an object');
431
+ }
432
+ return {
433
+ userId: requiredScopePart(scope.userId, 'userId'),
434
+ workspaceId: nullableWorkspacePart(scope.workspaceId),
435
+ resource: requiredScopePart(scope.resource, 'resource')
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Migrates and normalizes a persisted document without touching transport state.
441
+ * @param {unknown} value Persisted value, null, or a legacy version-0 value.
442
+ * @param {SavedViewScope} scope Expected scope.
443
+ * @param {SavedViewRegistryOptions} [options={}] Migration hooks.
444
+ * @returns {SavedViewDocument} Current normalized document.
445
+ */
446
+ export function normalizeSavedViewDocument(value, scope, options = {}) {
447
+ const normalizedScope = normalizeSavedViewScope(scope);
448
+ if (value == null) return emptySavedViewDocument(normalizedScope);
449
+ const migrated = migrateSavedViewDocument(value, options.migrations);
450
+ if (migrated.scope != null) {
451
+ const actualScope = normalizeSavedViewScope(/** @type {Record<string,unknown>} */ (migrated.scope));
452
+ if (scopeKey(actualScope) !== scopeKey(normalizedScope)) {
453
+ throw new SavedViewScopeMismatchError(normalizedScope, actualScope);
454
+ }
455
+ }
456
+ const rawViews = Array.isArray(migrated.views) ? migrated.views : [];
457
+ /** @type {SavedViewEntry[]} */
458
+ const candidates = rawViews.map((entry, index) => normalizePersistedEntry(entry, index, normalizedScope));
459
+ const deduplicated = deduplicateEntries(candidates);
460
+ const aliases = deduplicated.aliases;
461
+ const views = sortEntries(deduplicated.views);
462
+ const requestedDefault = migrated.defaultId == null ? null : String(migrated.defaultId);
463
+ const defaultId = views.some((entry) => entry.id === requestedDefault)
464
+ ? requestedDefault : aliases.get(requestedDefault) ?? null;
465
+ return {
466
+ version: SAVED_VIEWS_VERSION,
467
+ scope: normalizedScope,
468
+ defaultId: views.some((entry) => entry.id === defaultId) ? defaultId : null,
469
+ views
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Migrates old documents to the current structural version. Version 0 accepts an array or an
475
+ * object using `savedViews`/`entries`, `presentation`, `viewState`, `filter`, and `defaultView`.
476
+ * @param {unknown} value Persisted value.
477
+ * @param {Record<number,(document:Record<string,unknown>)=>Record<string,unknown>>} [migrations={}]
478
+ * Additional migrations keyed by source version.
479
+ * @returns {Record<string,unknown>} Migrated current-version document.
480
+ */
481
+ export function migrateSavedViewDocument(value, migrations = {}) {
482
+ let document = Array.isArray(value) ? { version: 0, savedViews: value }
483
+ : cloneJsonObject(value, 'Saved-view document');
484
+ let version = normalizeDocumentVersion(document.version);
485
+ if (version > SAVED_VIEWS_VERSION) {
486
+ throw new RangeError(`Unsupported saved-view document version: ${version}`);
487
+ }
488
+ while (version < SAVED_VIEWS_VERSION) {
489
+ const migrate = migrations?.[version] ?? (version === 0 ? migrateVersionZero : null);
490
+ if (typeof migrate !== 'function') {
491
+ throw new RangeError(`Missing saved-view migration from version ${version}`);
492
+ }
493
+ document = cloneJsonObject(migrate(cloneJsonObject(document, 'Saved-view document')),
494
+ `Saved-view migration ${version}`);
495
+ const nextVersion = normalizeDocumentVersion(document.version);
496
+ if (nextVersion <= version) {
497
+ throw new RangeError(`Saved-view migration ${version} did not advance the version`);
498
+ }
499
+ version = nextVersion;
500
+ }
501
+ return document;
502
+ }
503
+
504
+ /** @param {Record<string,unknown>} document @returns {Record<string,unknown>} */
505
+ function migrateVersionZero(document) {
506
+ const source = Array.isArray(document.savedViews) ? document.savedViews
507
+ : Array.isArray(document.entries) ? document.entries
508
+ : Array.isArray(document.views) ? document.views : [];
509
+ const views = source.map((value) => {
510
+ const entry = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
511
+ return {
512
+ id: entry.id ?? null,
513
+ name: entry.name ?? entry.label ?? '',
514
+ type: entry.type ?? entry.presentation ?? entry.view ?? '',
515
+ state: entry.state ?? entry.viewState ?? {},
516
+ filters: entry.filters ?? entry.filter ?? {},
517
+ search: entry.search ?? entry.query ?? '',
518
+ createdAt: entry.createdAt ?? entry.created ?? null,
519
+ updatedAt: entry.updatedAt ?? entry.updated ?? null
520
+ };
521
+ });
522
+ return {
523
+ version: 1,
524
+ scope: document.scope ?? null,
525
+ defaultId: document.defaultId ?? document.defaultView ?? document.default ?? null,
526
+ views
527
+ };
528
+ }
529
+
530
+ /** @param {unknown} value @param {number} index @param {SavedViewScope} scope @returns {SavedViewEntry} */
531
+ function normalizePersistedEntry(value, index, scope) {
532
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
533
+ throw new TypeError(`Saved view at index ${index} must be an object`);
534
+ }
535
+ const entry = /** @type {Record<string,unknown>} */ (value);
536
+ const name = normalizeName(entry.name);
537
+ const type = normalizeType(entry.type);
538
+ const state = normalizeRecordViewState(entry.state ?? {});
539
+ const filters = normalizeFilters(entry.filters ?? {});
540
+ const search = normalizeSearch(entry.search);
541
+ const createdAt = normalizeTimestamp(entry.createdAt);
542
+ const requestedUpdated = normalizeTimestamp(entry.updatedAt ?? createdAt);
543
+ const updatedAt = requestedUpdated < createdAt ? createdAt : requestedUpdated;
544
+ const id = entry.id == null || String(entry.id).trim() === ''
545
+ ? `legacy-${hashString(`${scopeKey(scope)}\u0000${name}\u0000${type}\u0000${index}`)}`
546
+ : normalizeId(entry.id);
547
+ return { id, name, type, state, filters, search, createdAt, updatedAt };
548
+ }
549
+
550
+ /**
551
+ * Resolves duplicate/corrupt persisted entries deterministically: newest update wins, then the
552
+ * lexicographically smaller stable id. Losing default ids are aliased to the retained entry.
553
+ * @param {SavedViewEntry[]} entries
554
+ * @returns {{views:SavedViewEntry[],aliases:Map<string,string>}}
555
+ */
556
+ function deduplicateEntries(entries) {
557
+ const ranked = [...entries].sort((left, right) => compareText(right.updatedAt, left.updatedAt)
558
+ || compareText(left.id, right.id));
559
+ const ids = new Set();
560
+ const names = new Set();
561
+ const aliases = new Map();
562
+ const views = [];
563
+ for (const entry of ranked) {
564
+ const duplicate = views.find((candidate) => candidate.id === entry.id
565
+ || nameKey(candidate.name) === nameKey(entry.name));
566
+ if (ids.has(entry.id) || names.has(nameKey(entry.name))) {
567
+ if (duplicate) aliases.set(entry.id, duplicate.id);
568
+ continue;
569
+ }
570
+ ids.add(entry.id);
571
+ names.add(nameKey(entry.name));
572
+ views.push(entry);
573
+ }
574
+ return { views, aliases };
575
+ }
576
+
577
+ /** @param {SavedViewRegistryOptions} options @returns {Required<Pick<SavedViewRegistryOptions,'duplicateNames'|'clock'>> & SavedViewRegistryOptions} */
578
+ function normalizeRegistryOptions(options) {
579
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
580
+ throw new TypeError('Saved-view registry options must be an object');
581
+ }
582
+ if (options.clock != null && typeof options.clock !== 'function') {
583
+ throw new TypeError('Saved-view clock must be a function');
584
+ }
585
+ if (options.idFactory != null && typeof options.idFactory !== 'function') {
586
+ throw new TypeError('Saved-view idFactory must be a function');
587
+ }
588
+ return {
589
+ ...options,
590
+ duplicateNames: normalizeDuplicatePolicy(options.duplicateNames ?? 'reject'),
591
+ clock: options.clock ?? (() => new Date())
592
+ };
593
+ }
594
+
595
+ /** @param {SavedViewScope} scope @returns {SavedViewDocument} */
596
+ function emptySavedViewDocument(scope) {
597
+ return { version: SAVED_VIEWS_VERSION, scope: { ...scope }, defaultId: null, views: [] };
598
+ }
599
+
600
+ /** @param {SavedViewDocument} document @returns {SavedViewDocument} */
601
+ function cloneSavedViewDocument(document) {
602
+ return {
603
+ version: SAVED_VIEWS_VERSION,
604
+ scope: { ...document.scope },
605
+ defaultId: document.defaultId,
606
+ views: document.views.map(cloneSavedViewEntry)
607
+ };
608
+ }
609
+
610
+ /** @param {SavedViewEntry} entry @returns {SavedViewEntry} */
611
+ function cloneSavedViewEntry(entry) {
612
+ return {
613
+ ...entry,
614
+ state: cloneJsonObject(entry.state, 'Saved view state'),
615
+ filters: cloneJsonObject(entry.filters, 'Saved view filters')
616
+ };
617
+ }
618
+
619
+ /** @template T @param {T} value @returns {T} */
620
+ function cloneResult(value) {
621
+ if (isSavedViewEntryLike(value)) {
622
+ return /** @type {T} */ (cloneSavedViewEntry(/** @type {SavedViewEntry} */ (value)));
623
+ }
624
+ return value;
625
+ }
626
+
627
+ /** @param {SavedViewDocument} document @param {string} idOrName @returns {SavedViewEntry|null} */
628
+ function resolveEntry(document, idOrName) {
629
+ if (typeof idOrName !== 'string') return null;
630
+ const value = idOrName.trim();
631
+ if (!value) return null;
632
+ return document.views.find((entry) => entry.id === value)
633
+ ?? document.views.find((entry) => nameKey(entry.name) === nameKey(value)) ?? null;
634
+ }
635
+
636
+ /** @param {SavedViewEntry[]} entries @returns {SavedViewEntry[]} */
637
+ function sortEntries(entries) {
638
+ return [...entries].sort((left, right) => compareText(nameKey(left.name), nameKey(right.name))
639
+ || compareText(left.name, right.name) || compareText(left.id, right.id));
640
+ }
641
+
642
+ /** @param {unknown} value @returns {SavedViewType} */
643
+ function normalizeType(value) {
644
+ if (typeof value !== 'string') {
645
+ throw new TypeError('Saved view type must be "table", "card", or "kanban"');
646
+ }
647
+ const type = value.trim().toLowerCase();
648
+ if (!TYPES.has(type)) throw new TypeError('Saved view type must be "table", "card", or "kanban"');
649
+ return /** @type {SavedViewType} */ (type);
650
+ }
651
+
652
+ /** @param {unknown} value @returns {string} */
653
+ function normalizeName(value) {
654
+ if (typeof value !== 'string') throw new TypeError('Saved view name must be a string');
655
+ const name = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
656
+ if (!name) throw new TypeError('Saved view name cannot be empty');
657
+ if (name.length > 160) throw new RangeError('Saved view name cannot exceed 160 characters');
658
+ return name;
659
+ }
660
+
661
+ /** @param {string} value @returns {string} */
662
+ function nameKey(value) {
663
+ return value.normalize('NFKC').toLocaleLowerCase('en-US');
664
+ }
665
+
666
+ /** @param {unknown} value @returns {string} */
667
+ function normalizeId(value) {
668
+ if (typeof value !== 'string') throw new TypeError('Saved view id must be a string');
669
+ const id = value.normalize('NFKC').trim();
670
+ if (!id) throw new TypeError('Saved view id cannot be empty');
671
+ if (id.length > 180) throw new RangeError('Saved view id cannot exceed 180 characters');
672
+ return id;
673
+ }
674
+
675
+ /** @param {unknown} value @returns {SavedViewDuplicatePolicy} */
676
+ function normalizeDuplicatePolicy(value) {
677
+ if (value !== 'reject' && value !== 'replace') {
678
+ throw new TypeError('Duplicate saved-view policy must be "reject" or "replace"');
679
+ }
680
+ return value;
681
+ }
682
+
683
+ /** @param {unknown} value @returns {Record<string,unknown>} */
684
+ function normalizeRecordViewState(value) {
685
+ const state = cloneJsonObject(value, 'Saved view state');
686
+ const version = Number(state.version);
687
+ if (!Number.isInteger(version) || version < 1) {
688
+ throw new TypeError('Saved view state requires a positive integer version');
689
+ }
690
+ state.version = version;
691
+ for (const key of FORBIDDEN_STATE_KEYS) {
692
+ if (Object.prototype.hasOwnProperty.call(state, key)) {
693
+ throw new TypeError(`Saved view state cannot serialize ${key}`);
694
+ }
695
+ }
696
+ return state;
697
+ }
698
+
699
+ /** @param {unknown} value @returns {Record<string,unknown>} */
700
+ function normalizeFilters(value) {
701
+ return cloneJsonObject(value ?? {}, 'Saved view filters');
702
+ }
703
+
704
+ /** @param {unknown} value @returns {string} */
705
+ function normalizeSearch(value) {
706
+ if (value == null) return '';
707
+ if (typeof value !== 'string') throw new TypeError('Saved view search must be a string');
708
+ return value;
709
+ }
710
+
711
+ /** @param {unknown} value @param {string} label @returns {Record<string,unknown>} */
712
+ function cloneJsonObject(value, label) {
713
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
714
+ throw new TypeError(`${label} must be a plain object`);
715
+ }
716
+ return /** @type {Record<string,unknown>} */ (cloneJsonValue(value, label, new WeakSet()));
717
+ }
718
+
719
+ /** @param {unknown} value @param {string} label @param {WeakSet<object>} seen @returns {unknown} */
720
+ function cloneJsonValue(value, label, seen) {
721
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
722
+ if (typeof value === 'number') {
723
+ if (!Number.isFinite(value)) throw new TypeError(`${label} cannot contain non-finite numbers`);
724
+ return value;
725
+ }
726
+ if (typeof value !== 'object') throw new TypeError(`${label} must be JSON-safe`);
727
+ if (typeof /** @type {{nodeType?:unknown}} */ (value).nodeType === 'number') {
728
+ throw new TypeError(`${label} cannot contain DOM nodes`);
729
+ }
730
+ if (seen.has(value)) throw new TypeError(`${label} cannot contain cycles`);
731
+ seen.add(value);
732
+ try {
733
+ if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item, label, seen));
734
+ const prototype = Object.getPrototypeOf(value);
735
+ if (prototype !== Object.prototype && prototype !== null) {
736
+ throw new TypeError(`${label} can contain only plain objects and arrays`);
737
+ }
738
+ /** @type {Record<string,unknown>} */
739
+ const result = {};
740
+ const object = /** @type {Record<string,unknown>} */ (value);
741
+ if (Object.getOwnPropertySymbols(value).length > 0) {
742
+ throw new TypeError(`${label} cannot contain symbols`);
743
+ }
744
+ for (const key of Object.keys(value).sort()) {
745
+ if (FORBIDDEN_JSON_KEYS.has(key)) {
746
+ throw new TypeError(`${label} cannot contain prototype-pollution keys`);
747
+ }
748
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
749
+ if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
750
+ throw new TypeError(`${label} cannot contain property accessors`);
751
+ }
752
+ result[key] = cloneJsonValue(descriptor.value, label, seen);
753
+ }
754
+ return result;
755
+ } finally {
756
+ seen.delete(value);
757
+ }
758
+ }
759
+
760
+ /** @param {unknown} value @returns {string} */
761
+ function normalizeTimestamp(value) {
762
+ if (value == null || value === '') return EPOCH;
763
+ const date = new Date(/** @type {string|number|Date} */ (value));
764
+ return Number.isFinite(date.getTime()) ? date.toISOString() : EPOCH;
765
+ }
766
+
767
+ /** @param {unknown} value @returns {number} */
768
+ function normalizeDocumentVersion(value) {
769
+ if (value == null) return 0;
770
+ const version = Number(value);
771
+ if (!Number.isInteger(version) || version < 0) throw new TypeError('Saved-view document version must be a non-negative integer');
772
+ return version;
773
+ }
774
+
775
+ /** @param {unknown} value @param {string} key @returns {string} */
776
+ function requiredScopePart(value, key) {
777
+ if (typeof value !== 'string' && typeof value !== 'number') {
778
+ throw new TypeError(`Saved-view scope ${key} must be a string or number`);
779
+ }
780
+ const text = String(value).normalize('NFKC').trim();
781
+ if (!text) throw new TypeError(`Saved-view scope ${key} cannot be empty`);
782
+ return text;
783
+ }
784
+
785
+ /** @param {unknown} value @returns {string|null} */
786
+ function nullableWorkspacePart(value) {
787
+ if (value === null) return null;
788
+ return requiredScopePart(value, 'workspaceId');
789
+ }
790
+
791
+ /** @param {SavedViewScope} scope @returns {string} */
792
+ function scopeKey(scope) {
793
+ const workspace = scope.workspaceId === null ? '\u0001' : `\u0002${scope.workspaceId}`;
794
+ return `${scope.userId}\u0000${workspace}\u0000${scope.resource}`;
795
+ }
796
+
797
+ /** @param {string} left @param {string} right @returns {number} */
798
+ function compareText(left, right) {
799
+ return left < right ? -1 : left > right ? 1 : 0;
800
+ }
801
+
802
+ /** @param {unknown} value @returns {value is SavedViewEntry} */
803
+ function isSavedViewEntryLike(value) {
804
+ return Boolean(value && typeof value === 'object' && 'id' in value && typeof value.id === 'string');
805
+ }
806
+
807
+ /** Small deterministic FNV-1a hash encoded for readable ids. @param {string} value @returns {string} */
808
+ function hashString(value) {
809
+ let hash = 0x811c9dc5;
810
+ for (let index = 0; index < value.length; index += 1) {
811
+ hash ^= value.charCodeAt(index);
812
+ hash = Math.imul(hash, 0x01000193);
813
+ }
814
+ return (hash >>> 0).toString(36);
815
+ }