@happyvertical/smrt-ui 0.42.4 → 0.42.5

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 (48) hide show
  1. package/README.md +157 -3
  2. package/dist/components/data/DataTable.svelte +1097 -111
  3. package/dist/components/data/DataTable.svelte.d.ts +1 -1
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableController.d.ts +45 -5
  6. package/dist/components/data/DataTableController.d.ts.map +1 -1
  7. package/dist/components/data/DataTableController.js +153 -11
  8. package/dist/components/data/DataTableLayout.d.ts +37 -0
  9. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  10. package/dist/components/data/DataTableLayout.js +135 -0
  11. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  12. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  13. package/dist/components/data/DataTablePerformance.js +46 -0
  14. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  15. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  16. package/dist/components/data/DataTableVirtualization.js +100 -0
  17. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  18. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  19. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  20. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  21. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  22. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  23. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  24. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  26. package/dist/components/data/__tests__/DataTable.test.js +393 -8
  27. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  28. package/dist/components/data/__tests__/DataTableController.test.js +124 -2
  29. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  30. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  31. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  32. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  33. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  34. package/dist/components/data/data-surface.d.ts +246 -0
  35. package/dist/components/data/data-surface.d.ts.map +1 -0
  36. package/dist/components/data/data-surface.js +1102 -0
  37. package/dist/components/data/index.d.ts +3 -0
  38. package/dist/components/data/index.d.ts.map +1 -1
  39. package/dist/components/data/index.js +3 -0
  40. package/dist/components/data/types.d.ts +83 -1
  41. package/dist/components/data/types.d.ts.map +1 -1
  42. package/dist/i18n/strings.d.ts +25 -0
  43. package/dist/i18n/strings.d.ts.map +1 -1
  44. package/dist/i18n/strings.js +27 -2
  45. package/dist/svelte/playground/DataTablePreview.svelte +374 -5
  46. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  47. package/dist/svelte/playground.js +2 -2
  48. package/package.json +3 -2
@@ -0,0 +1,1102 @@
1
+ /**
2
+ * Transport-neutral contracts for mounted, addressable data surfaces.
3
+ *
4
+ * This module is intentionally a leaf: it owns no transport, DOM, query
5
+ * execution, authorization, persistence, or domain workflow. A browser host
6
+ * may register a mounted surface here; adapters above smrt-ui can expose the
7
+ * resulting serializable contracts to people or agents.
8
+ */
9
+ /** Generic browser-transport ceiling for normalized command/action envelopes. */
10
+ export const DATA_SURFACE_MAX_REQUEST_BYTES = 100_000;
11
+ /** Per-surface LRU capacity for command acknowledgement replay. */
12
+ export const DATA_SURFACE_MAX_REPLAY_ENTRIES = 100;
13
+ const MAX_QUERY_LIMIT = 1_000;
14
+ const MAX_REQUEST_ID_LENGTH = 256;
15
+ const MAX_CURSOR_LENGTH = 2_048;
16
+ const MAX_JSON_DEPTH = 16;
17
+ const MAX_JSON_CONTAINER_ITEMS = 1_000;
18
+ const PROTOTYPE_POLLUTION_KEYS = new Set([
19
+ '__proto__',
20
+ 'constructor',
21
+ 'prototype',
22
+ ]);
23
+ const DATA_SURFACE_KINDS = new Set([
24
+ 'table',
25
+ 'list',
26
+ 'report',
27
+ 'custom',
28
+ ]);
29
+ const SENSITIVITIES = new Set([
30
+ 'public',
31
+ 'personal',
32
+ 'sensitive',
33
+ 'secret',
34
+ ]);
35
+ const COLUMN_CAPABILITIES = new Set([
36
+ 'read',
37
+ 'search',
38
+ 'filter',
39
+ 'sort',
40
+ 'project',
41
+ ]);
42
+ const QUERY_MODES = new Set(['rows', 'count', 'facets']);
43
+ const SELECTION_SCOPES = new Set([
44
+ 'current-page',
45
+ 'explicit-ids',
46
+ 'all-matching',
47
+ ]);
48
+ const FORBIDDEN_BOUNDARY_KEYS = new Set([
49
+ 'tenant',
50
+ 'tenantid',
51
+ 'principalid',
52
+ 'principal',
53
+ 'actorid',
54
+ 'auth',
55
+ 'authtoken',
56
+ 'authorization',
57
+ 'authorizationtoken',
58
+ 'authorizationheader',
59
+ 'authentication',
60
+ 'authenticationtoken',
61
+ 'accesstoken',
62
+ 'token',
63
+ 'bearer',
64
+ 'bearertoken',
65
+ 'apikey',
66
+ 'sessiontoken',
67
+ 'credential',
68
+ 'credentials',
69
+ 'sql',
70
+ 'rawsql',
71
+ 'rawquery',
72
+ 'where',
73
+ ]);
74
+ function plainObject(value, label) {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
76
+ throw new TypeError(`${label} must be a plain object`);
77
+ }
78
+ if (Object.getPrototypeOf(value) !== Object.prototype) {
79
+ throw new TypeError(`${label} must be a plain object`);
80
+ }
81
+ return value;
82
+ }
83
+ function exactKeys(value, allowed, label) {
84
+ const allowedKeys = new Set(allowed);
85
+ for (const key of Object.keys(value)) {
86
+ if (!allowedKeys.has(key)) {
87
+ throw new TypeError(`${label} contains unsupported field: ${key}`);
88
+ }
89
+ }
90
+ }
91
+ function stringValue(value, label) {
92
+ if (typeof value !== 'string' || value.length === 0) {
93
+ throw new TypeError(`${label} must be a non-empty string`);
94
+ }
95
+ return value;
96
+ }
97
+ function optionalString(value, label) {
98
+ if (value === undefined)
99
+ return undefined;
100
+ return stringValue(value, label);
101
+ }
102
+ function positiveInteger(value, label) {
103
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
104
+ throw new TypeError(`${label} must be a positive safe integer`);
105
+ }
106
+ return value;
107
+ }
108
+ function revisionNumber(value) {
109
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
110
+ throw new TypeError('DataSurface revision must be a non-negative safe integer');
111
+ }
112
+ return value;
113
+ }
114
+ function consumeJsonBytes(budget, byteLength) {
115
+ if (!budget)
116
+ return;
117
+ assertJsonBytesAvailable(budget, byteLength);
118
+ budget.used += byteLength;
119
+ }
120
+ function assertJsonBytesAvailable(budget, byteLength, used = budget?.used ?? 0) {
121
+ if (!budget)
122
+ return;
123
+ if (byteLength > budget.limit - used) {
124
+ throw new TypeError(`${budget.label} cannot exceed ${budget.limit} UTF-8 bytes`);
125
+ }
126
+ }
127
+ /** UTF-8 byte length of JSON.stringify(value), limited to a string value. */
128
+ function jsonStringByteLength(value, budget, used = budget?.used ?? 0) {
129
+ let byteLength = 2; // Opening and closing quotes.
130
+ for (let index = 0; index < value.length; index += 1) {
131
+ const codeUnit = value.charCodeAt(index);
132
+ if (codeUnit === 0x22 || codeUnit === 0x5c) {
133
+ byteLength += 2;
134
+ }
135
+ else if (codeUnit === 0x08 ||
136
+ codeUnit === 0x09 ||
137
+ codeUnit === 0x0a ||
138
+ codeUnit === 0x0c ||
139
+ codeUnit === 0x0d) {
140
+ byteLength += 2;
141
+ }
142
+ else if (codeUnit <= 0x1f) {
143
+ byteLength += 6;
144
+ }
145
+ else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
146
+ const next = value.charCodeAt(index + 1);
147
+ if (next >= 0xdc00 && next <= 0xdfff) {
148
+ byteLength += 4;
149
+ index += 1;
150
+ }
151
+ else {
152
+ byteLength += 6;
153
+ }
154
+ }
155
+ else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
156
+ byteLength += 6;
157
+ }
158
+ else if (codeUnit <= 0x7f) {
159
+ byteLength += 1;
160
+ }
161
+ else if (codeUnit <= 0x7ff) {
162
+ byteLength += 2;
163
+ }
164
+ else {
165
+ byteLength += 3;
166
+ }
167
+ assertJsonBytesAvailable(budget, byteLength, used);
168
+ }
169
+ return byteLength;
170
+ }
171
+ /** Preflight only: actual object bytes are consumed in canonicalJson below. */
172
+ function assertJsonObjectKeysWithinBudget(keys, budget) {
173
+ if (!budget)
174
+ return;
175
+ let used = budget.used;
176
+ const structuralBytes = keys.length === 0 ? 2 : keys.length * 2 + 1;
177
+ assertJsonBytesAvailable(budget, structuralBytes, used);
178
+ used += structuralBytes;
179
+ for (const key of keys) {
180
+ used += jsonStringByteLength(key, budget, used);
181
+ }
182
+ }
183
+ function canonicalJson(value, ancestors = new Set(), depth = 0, budget) {
184
+ if (depth > MAX_JSON_DEPTH) {
185
+ throw new TypeError(`DataSurface values cannot exceed ${MAX_JSON_DEPTH} nested levels`);
186
+ }
187
+ if (value === null ||
188
+ typeof value === 'string' ||
189
+ typeof value === 'boolean') {
190
+ consumeJsonBytes(budget, value === null
191
+ ? 4
192
+ : typeof value === 'boolean'
193
+ ? value
194
+ ? 4
195
+ : 5
196
+ : jsonStringByteLength(value, budget));
197
+ return value;
198
+ }
199
+ if (typeof value === 'number') {
200
+ if (!Number.isFinite(value)) {
201
+ throw new TypeError('DataSurface values cannot contain non-finite numbers');
202
+ }
203
+ const normalized = value === 0 ? 0 : value;
204
+ consumeJsonBytes(budget, String(normalized).length);
205
+ return normalized;
206
+ }
207
+ if (Array.isArray(value)) {
208
+ if (value.length > MAX_JSON_CONTAINER_ITEMS) {
209
+ throw new TypeError(`DataSurface arrays cannot contain more than ${MAX_JSON_CONTAINER_ITEMS} items`);
210
+ }
211
+ if (ancestors.has(value)) {
212
+ throw new TypeError('DataSurface values cannot be circular');
213
+ }
214
+ ancestors.add(value);
215
+ consumeJsonBytes(budget, 1);
216
+ const clone = [];
217
+ for (const [index, entry] of value.entries()) {
218
+ if (index > 0)
219
+ consumeJsonBytes(budget, 1);
220
+ clone.push(canonicalJson(entry, ancestors, depth + 1, budget));
221
+ }
222
+ consumeJsonBytes(budget, 1);
223
+ ancestors.delete(value);
224
+ return clone;
225
+ }
226
+ if (value && typeof value === 'object') {
227
+ const object = plainObject(value, 'DataSurface value');
228
+ if (ancestors.has(object)) {
229
+ throw new TypeError('DataSurface values cannot be circular');
230
+ }
231
+ ancestors.add(object);
232
+ const clone = {};
233
+ const keys = Object.keys(object);
234
+ if (keys.length > MAX_JSON_CONTAINER_ITEMS) {
235
+ throw new TypeError(`DataSurface objects cannot contain more than ${MAX_JSON_CONTAINER_ITEMS} keys`);
236
+ }
237
+ assertJsonObjectKeysWithinBudget(keys, budget);
238
+ consumeJsonBytes(budget, 1);
239
+ for (const [index, key] of keys.sort().entries()) {
240
+ if (index > 0)
241
+ consumeJsonBytes(budget, 1);
242
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
243
+ throw new TypeError(`DataSurface values cannot contain prototype key: ${key}`);
244
+ }
245
+ consumeJsonBytes(budget, jsonStringByteLength(key, budget) + 1);
246
+ clone[key] = canonicalJson(object[key], ancestors, depth + 1, budget);
247
+ }
248
+ consumeJsonBytes(budget, 1);
249
+ ancestors.delete(object);
250
+ return clone;
251
+ }
252
+ throw new TypeError('DataSurface values must be JSON-safe plain data');
253
+ }
254
+ function jsonObject(value, label) {
255
+ const clone = canonicalJson(value);
256
+ if (Array.isArray(clone) || clone === null || typeof clone !== 'object') {
257
+ throw new TypeError(`${label} must be a JSON object`);
258
+ }
259
+ return clone;
260
+ }
261
+ function jsonSignature(value) {
262
+ return JSON.stringify(canonicalJson(value));
263
+ }
264
+ /** The UTF-8 size of the normalized, canonical query envelope. */
265
+ function canonicalQueryByteLength(request) {
266
+ const budget = {
267
+ used: 0,
268
+ limit: Number.MAX_SAFE_INTEGER,
269
+ label: 'DataSurface query request',
270
+ };
271
+ canonicalJson(request, new Set(), 0, budget);
272
+ return budget.used;
273
+ }
274
+ function assertRequestByteLimit(value, label) {
275
+ canonicalJson(value, new Set(), 0, {
276
+ used: 0,
277
+ limit: DATA_SURFACE_MAX_REQUEST_BYTES,
278
+ label,
279
+ });
280
+ }
281
+ function boundarySafe(value, label) {
282
+ const clone = canonicalJson(value);
283
+ const inspect = (entry) => {
284
+ if (Array.isArray(entry)) {
285
+ for (const item of entry)
286
+ inspect(item);
287
+ return;
288
+ }
289
+ if (entry && typeof entry === 'object') {
290
+ for (const [key, item] of Object.entries(entry)) {
291
+ const normalizedKey = key.replaceAll(/[-_]/g, '').toLowerCase();
292
+ if (FORBIDDEN_BOUNDARY_KEYS.has(normalizedKey)) {
293
+ throw new TypeError(`${label} cannot contain authority or SQL field: ${key}`);
294
+ }
295
+ inspect(item);
296
+ }
297
+ }
298
+ };
299
+ inspect(clone);
300
+ return clone;
301
+ }
302
+ function boundarySafeObject(value, label) {
303
+ const clone = boundarySafe(value, label);
304
+ if (Array.isArray(clone) || clone === null || typeof clone !== 'object') {
305
+ throw new TypeError(`${label} must be a JSON object`);
306
+ }
307
+ return clone;
308
+ }
309
+ function normalizeIdentity(value) {
310
+ const object = plainObject(value, 'DataSurface identity');
311
+ exactKeys(object, ['surfaceId', 'kind', 'subject'], 'DataSurface identity');
312
+ const kind = stringValue(object.kind, 'DataSurface kind');
313
+ if (!DATA_SURFACE_KINDS.has(kind)) {
314
+ throw new TypeError(`Unsupported DataSurface kind: ${kind}`);
315
+ }
316
+ let subject;
317
+ if (object.subject !== undefined) {
318
+ const source = plainObject(object.subject, 'DataSurface subject');
319
+ exactKeys(source, ['type', 'id', 'label'], 'DataSurface subject');
320
+ const label = optionalString(source.label, 'DataSurface subject label');
321
+ subject = {
322
+ type: stringValue(source.type, 'DataSurface subject type'),
323
+ id: stringValue(source.id, 'DataSurface subject id'),
324
+ ...(label ? { label } : {}),
325
+ };
326
+ }
327
+ return {
328
+ surfaceId: stringValue(object.surfaceId, 'DataSurface surface id'),
329
+ kind,
330
+ ...(subject ? { subject } : {}),
331
+ };
332
+ }
333
+ function identityKey(identity) {
334
+ return JSON.stringify([
335
+ identity.kind,
336
+ identity.surfaceId,
337
+ identity.subject ? [identity.subject.type, identity.subject.id] : null,
338
+ ]);
339
+ }
340
+ function cloneIdentity(identity) {
341
+ return normalizeIdentity(identity);
342
+ }
343
+ function normalizeStringArray(value, label, options = {}) {
344
+ if (!Array.isArray(value))
345
+ throw new TypeError(`${label} must be an array`);
346
+ if (value.length > MAX_JSON_CONTAINER_ITEMS) {
347
+ throw new TypeError(`${label} cannot contain more than ${MAX_JSON_CONTAINER_ITEMS} items`);
348
+ }
349
+ const values = value.map((entry) => stringValue(entry, label));
350
+ if (new Set(values).size !== values.length) {
351
+ throw new TypeError(`${label} cannot contain duplicates`);
352
+ }
353
+ return options.sort ? values.sort() : values;
354
+ }
355
+ function normalizeCapabilityArray(value, label) {
356
+ const capabilities = normalizeStringArray(value, label, { sort: true });
357
+ for (const capability of capabilities) {
358
+ if (!COLUMN_CAPABILITIES.has(capability)) {
359
+ throw new TypeError(`Unsupported DataSurface column capability: ${capability}`);
360
+ }
361
+ }
362
+ return capabilities;
363
+ }
364
+ function normalizeSensitivity(value, label) {
365
+ if (value === undefined)
366
+ return undefined;
367
+ const sensitivity = stringValue(value, label);
368
+ if (!SENSITIVITIES.has(sensitivity)) {
369
+ throw new TypeError(`Unsupported DataSurface sensitivity: ${sensitivity}`);
370
+ }
371
+ return sensitivity;
372
+ }
373
+ function normalizeSelection(value) {
374
+ const object = plainObject(value, 'DataSurface selection');
375
+ const scope = stringValue(object.scope, 'DataSurface selection scope');
376
+ if (!SELECTION_SCOPES.has(scope)) {
377
+ throw new TypeError(`Unsupported DataSurface selection scope: ${scope}`);
378
+ }
379
+ if (scope === 'current-page') {
380
+ exactKeys(object, ['scope'], 'DataSurface current-page selection');
381
+ return { scope };
382
+ }
383
+ if (scope === 'explicit-ids') {
384
+ exactKeys(object, ['scope', 'rowIds'], 'DataSurface explicit selection');
385
+ if (!Array.isArray(object.rowIds)) {
386
+ throw new TypeError('DataSurface explicit selection rowIds must be an array');
387
+ }
388
+ if (object.rowIds.length > MAX_JSON_CONTAINER_ITEMS) {
389
+ throw new TypeError(`DataSurface explicit selection cannot contain more than ${MAX_JSON_CONTAINER_ITEMS} row ids`);
390
+ }
391
+ const rowIds = new Map();
392
+ for (const rowId of object.rowIds) {
393
+ if (typeof rowId !== 'string' &&
394
+ (typeof rowId !== 'number' || !Number.isFinite(rowId))) {
395
+ throw new TypeError('DataSurface row ids must be finite strings or numbers');
396
+ }
397
+ const normalized = typeof rowId === 'number' && rowId === 0 ? 0 : rowId;
398
+ rowIds.set(`${typeof normalized}:${String(normalized)}`, normalized);
399
+ }
400
+ return {
401
+ scope,
402
+ rowIds: [...rowIds.values()].sort((left, right) => {
403
+ if (typeof left !== typeof right)
404
+ return typeof left === 'number' ? -1 : 1;
405
+ if (typeof left === 'number' && typeof right === 'number')
406
+ return left - right;
407
+ return left < right ? -1 : left > right ? 1 : 0;
408
+ }),
409
+ };
410
+ }
411
+ exactKeys(object, ['scope', 'queryFingerprint'], 'DataSurface all-matching selection');
412
+ return {
413
+ scope,
414
+ queryFingerprint: stringValue(object.queryFingerprint, 'DataSurface query fingerprint'),
415
+ };
416
+ }
417
+ export function normalizeDataSurfaceDescriptor(value) {
418
+ const object = plainObject(value, 'DataSurface descriptor');
419
+ exactKeys(object, [
420
+ 'version',
421
+ 'identity',
422
+ 'schemaVersion',
423
+ 'label',
424
+ 'description',
425
+ 'rowKey',
426
+ 'columns',
427
+ 'query',
428
+ 'controls',
429
+ 'actions',
430
+ 'limits',
431
+ ], 'DataSurface descriptor');
432
+ if (object.version !== 1) {
433
+ throw new TypeError('Unsupported DataSurface descriptor version');
434
+ }
435
+ if (!Array.isArray(object.columns)) {
436
+ throw new TypeError('DataSurface descriptor columns must be an array');
437
+ }
438
+ const columns = object.columns.map((value) => {
439
+ const column = plainObject(value, 'DataSurface column');
440
+ exactKeys(column, ['id', 'label', 'description', 'sensitivity', 'capabilities'], 'DataSurface column');
441
+ const sensitivity = normalizeSensitivity(column.sensitivity, 'DataSurface column sensitivity');
442
+ const description = optionalString(column.description, 'DataSurface column description');
443
+ return {
444
+ id: stringValue(column.id, 'DataSurface column id'),
445
+ label: stringValue(column.label, 'DataSurface column label'),
446
+ ...(description ? { description } : {}),
447
+ ...(sensitivity ? { sensitivity } : {}),
448
+ capabilities: normalizeCapabilityArray(column.capabilities, 'DataSurface column capabilities'),
449
+ };
450
+ });
451
+ if (new Set(columns.map((column) => column.id)).size !== columns.length) {
452
+ throw new TypeError('DataSurface descriptor column ids must be unique');
453
+ }
454
+ const query = plainObject(object.query, 'DataSurface query capabilities');
455
+ exactKeys(query, ['modes', 'projectableColumnIds'], 'DataSurface query capabilities');
456
+ const modes = normalizeStringArray(query.modes, 'DataSurface query modes', {
457
+ sort: true,
458
+ });
459
+ for (const mode of modes) {
460
+ if (!QUERY_MODES.has(mode)) {
461
+ throw new TypeError(`Unsupported DataSurface query mode: ${mode}`);
462
+ }
463
+ }
464
+ const projectableColumnIds = normalizeStringArray(query.projectableColumnIds, 'DataSurface projectable column ids', { sort: true });
465
+ const knownColumns = new Set(columns.map((column) => column.id));
466
+ for (const columnId of projectableColumnIds) {
467
+ if (!knownColumns.has(columnId)) {
468
+ throw new TypeError(`Unknown projectable DataSurface column: ${columnId}`);
469
+ }
470
+ }
471
+ if (!Array.isArray(object.controls)) {
472
+ throw new TypeError('DataSurface controls must be an array');
473
+ }
474
+ const controls = object.controls.map((value) => {
475
+ const control = plainObject(value, 'DataSurface control');
476
+ exactKeys(control, ['id', 'label', 'description'], 'DataSurface control');
477
+ const description = optionalString(control.description, 'DataSurface control description');
478
+ return {
479
+ id: stringValue(control.id, 'DataSurface control id'),
480
+ label: stringValue(control.label, 'DataSurface control label'),
481
+ ...(description ? { description } : {}),
482
+ };
483
+ });
484
+ if (new Set(controls.map((control) => control.id)).size !== controls.length) {
485
+ throw new TypeError('DataSurface control ids must be unique');
486
+ }
487
+ if (!Array.isArray(object.actions)) {
488
+ throw new TypeError('DataSurface actions must be an array');
489
+ }
490
+ const actions = object.actions.map((value) => {
491
+ const action = plainObject(value, 'DataSurface action');
492
+ exactKeys(action, [
493
+ 'id',
494
+ 'label',
495
+ 'description',
496
+ 'sensitivity',
497
+ 'selectionScopes',
498
+ 'requiresConfirmation',
499
+ ], 'DataSurface action');
500
+ if (action.requiresConfirmation !== undefined &&
501
+ typeof action.requiresConfirmation !== 'boolean') {
502
+ throw new TypeError('DataSurface action requiresConfirmation must be boolean');
503
+ }
504
+ const scopes = normalizeStringArray(action.selectionScopes, 'DataSurface action selection scopes', { sort: true });
505
+ for (const scope of scopes) {
506
+ if (!SELECTION_SCOPES.has(scope)) {
507
+ throw new TypeError(`Unsupported DataSurface action selection scope: ${scope}`);
508
+ }
509
+ }
510
+ const sensitivity = normalizeSensitivity(action.sensitivity, 'DataSurface action sensitivity');
511
+ const description = optionalString(action.description, 'DataSurface action description');
512
+ return {
513
+ id: stringValue(action.id, 'DataSurface action id'),
514
+ label: stringValue(action.label, 'DataSurface action label'),
515
+ ...(description ? { description } : {}),
516
+ ...(sensitivity ? { sensitivity } : {}),
517
+ selectionScopes: scopes,
518
+ ...(action.requiresConfirmation === undefined
519
+ ? {}
520
+ : { requiresConfirmation: action.requiresConfirmation }),
521
+ };
522
+ });
523
+ if (new Set(actions.map((action) => action.id)).size !== actions.length) {
524
+ throw new TypeError('DataSurface action ids must be unique');
525
+ }
526
+ const limits = plainObject(object.limits, 'DataSurface limits');
527
+ exactKeys(limits, ['maxQueryRows', 'maxQueryBytes', 'maxSelectionSize'], 'DataSurface limits');
528
+ const maxQueryRows = positiveInteger(limits.maxQueryRows, 'DataSurface maxQueryRows');
529
+ if (maxQueryRows > MAX_QUERY_LIMIT) {
530
+ throw new TypeError(`DataSurface maxQueryRows cannot exceed ${MAX_QUERY_LIMIT}`);
531
+ }
532
+ const rowKey = stringValue(object.rowKey, 'DataSurface row key');
533
+ if (!knownColumns.has(rowKey)) {
534
+ throw new TypeError('DataSurface rowKey must name a declared column');
535
+ }
536
+ const description = optionalString(object.description, 'DataSurface description');
537
+ return {
538
+ version: 1,
539
+ identity: normalizeIdentity(object.identity),
540
+ schemaVersion: positiveInteger(object.schemaVersion, 'DataSurface schema version'),
541
+ label: stringValue(object.label, 'DataSurface label'),
542
+ ...(description ? { description } : {}),
543
+ rowKey,
544
+ columns,
545
+ query: { modes, projectableColumnIds },
546
+ controls,
547
+ actions,
548
+ limits: {
549
+ maxQueryRows,
550
+ maxQueryBytes: positiveInteger(limits.maxQueryBytes, 'DataSurface maxQueryBytes'),
551
+ maxSelectionSize: positiveInteger(limits.maxSelectionSize, 'DataSurface maxSelectionSize'),
552
+ },
553
+ };
554
+ }
555
+ export function normalizeDataSurfaceSnapshot(value) {
556
+ const object = plainObject(value, 'DataSurface snapshot');
557
+ exactKeys(object, ['version', 'descriptor', 'revision', 'state', 'selection'], 'DataSurface snapshot');
558
+ if (object.version !== 1) {
559
+ throw new TypeError('Unsupported DataSurface snapshot version');
560
+ }
561
+ return {
562
+ version: 1,
563
+ descriptor: normalizeDataSurfaceDescriptor(object.descriptor),
564
+ revision: revisionNumber(object.revision),
565
+ state: boundarySafeObject(object.state, 'DataSurface snapshot state'),
566
+ selection: object.selection === null || object.selection === undefined
567
+ ? null
568
+ : normalizeSelection(object.selection),
569
+ };
570
+ }
571
+ export function normalizeDataSurfaceVisibleCommand(value) {
572
+ const object = plainObject(value, 'DataSurface visible command');
573
+ exactKeys(object, [
574
+ 'version',
575
+ 'commandId',
576
+ 'identity',
577
+ 'expectedRevision',
578
+ 'controlId',
579
+ 'payload',
580
+ ], 'DataSurface visible command');
581
+ if (object.version !== 1) {
582
+ throw new TypeError('Unsupported DataSurface visible command version');
583
+ }
584
+ const commandId = stringValue(object.commandId, 'DataSurface command id');
585
+ if (commandId.length > MAX_REQUEST_ID_LENGTH) {
586
+ throw new TypeError('DataSurface command id is too long');
587
+ }
588
+ const commandIdentity = normalizeIdentity(object.identity);
589
+ const expectedRevision = revisionNumber(object.expectedRevision);
590
+ const controlId = stringValue(object.controlId, 'DataSurface control id');
591
+ assertRequestByteLimit(value, 'DataSurface visible command');
592
+ const normalized = {
593
+ version: 1,
594
+ commandId,
595
+ identity: commandIdentity,
596
+ expectedRevision,
597
+ controlId,
598
+ ...(object.payload === undefined
599
+ ? {}
600
+ : {
601
+ payload: boundarySafe(object.payload, 'DataSurface command payload'),
602
+ }),
603
+ };
604
+ assertRequestByteLimit(normalized, 'DataSurface visible command');
605
+ return normalized;
606
+ }
607
+ export function normalizeDataSurfaceQueryRequest(value) {
608
+ const object = plainObject(value, 'DataSurface query request');
609
+ const kind = stringValue(object.kind, 'DataSurface query kind');
610
+ if (object.version !== 1) {
611
+ throw new TypeError('Unsupported DataSurface query request version');
612
+ }
613
+ const requestId = stringValue(object.requestId, 'DataSurface query request id');
614
+ if (requestId.length > MAX_REQUEST_ID_LENGTH) {
615
+ throw new TypeError('DataSurface query request id is too long');
616
+ }
617
+ const identity = normalizeIdentity(object.identity);
618
+ if (kind === 'rows') {
619
+ exactKeys(object, [
620
+ 'version',
621
+ 'requestId',
622
+ 'identity',
623
+ 'kind',
624
+ 'limit',
625
+ 'cursor',
626
+ 'projection',
627
+ ], 'DataSurface rows query request');
628
+ assertRequestByteLimit(value, 'DataSurface query request');
629
+ const limit = positiveInteger(object.limit, 'DataSurface rows query limit');
630
+ if (limit > MAX_QUERY_LIMIT) {
631
+ throw new TypeError(`DataSurface rows query limit cannot exceed ${MAX_QUERY_LIMIT}`);
632
+ }
633
+ const cursor = optionalString(object.cursor, 'DataSurface rows query cursor');
634
+ if (cursor && cursor.length > MAX_CURSOR_LENGTH) {
635
+ throw new TypeError('DataSurface rows query cursor is too long');
636
+ }
637
+ return {
638
+ version: 1,
639
+ requestId,
640
+ identity,
641
+ kind,
642
+ limit,
643
+ ...(cursor ? { cursor } : {}),
644
+ ...(object.projection === undefined
645
+ ? {}
646
+ : {
647
+ projection: normalizeStringArray(object.projection, 'DataSurface query projection', { sort: true }),
648
+ }),
649
+ };
650
+ }
651
+ if (kind === 'count') {
652
+ exactKeys(object, ['version', 'requestId', 'identity', 'kind'], 'DataSurface count query request');
653
+ assertRequestByteLimit(value, 'DataSurface query request');
654
+ return { version: 1, requestId, identity, kind };
655
+ }
656
+ if (kind === 'facets') {
657
+ exactKeys(object, ['version', 'requestId', 'identity', 'kind', 'columnId', 'limit'], 'DataSurface facets query request');
658
+ assertRequestByteLimit(value, 'DataSurface query request');
659
+ const limit = positiveInteger(object.limit, 'DataSurface facets query limit');
660
+ if (limit > MAX_QUERY_LIMIT) {
661
+ throw new TypeError(`DataSurface facets query limit cannot exceed ${MAX_QUERY_LIMIT}`);
662
+ }
663
+ return {
664
+ version: 1,
665
+ requestId,
666
+ identity,
667
+ kind,
668
+ columnId: stringValue(object.columnId, 'DataSurface facets query column id'),
669
+ limit,
670
+ };
671
+ }
672
+ throw new TypeError(`Unsupported DataSurface query kind: ${kind}`);
673
+ }
674
+ export function normalizeDataSurfaceActionRequest(value) {
675
+ const object = plainObject(value, 'DataSurface action request');
676
+ exactKeys(object, [
677
+ 'version',
678
+ 'requestId',
679
+ 'identity',
680
+ 'actionId',
681
+ 'phase',
682
+ 'selection',
683
+ 'payload',
684
+ 'confirmationToken',
685
+ ], 'DataSurface action request');
686
+ if (object.version !== 1) {
687
+ throw new TypeError('Unsupported DataSurface action request version');
688
+ }
689
+ const requestId = stringValue(object.requestId, 'DataSurface action request id');
690
+ if (requestId.length > MAX_REQUEST_ID_LENGTH) {
691
+ throw new TypeError('DataSurface action request id is too long');
692
+ }
693
+ const phase = stringValue(object.phase, 'DataSurface action phase');
694
+ if (phase !== 'preview' && phase !== 'apply') {
695
+ throw new TypeError(`Unsupported DataSurface action phase: ${phase}`);
696
+ }
697
+ const confirmationToken = optionalString(object.confirmationToken, 'DataSurface confirmation token');
698
+ const actionIdentity = normalizeIdentity(object.identity);
699
+ const actionId = stringValue(object.actionId, 'DataSurface action id');
700
+ const selection = normalizeSelection(object.selection);
701
+ assertRequestByteLimit(value, 'DataSurface action request');
702
+ const normalized = {
703
+ version: 1,
704
+ requestId,
705
+ identity: actionIdentity,
706
+ actionId,
707
+ phase,
708
+ selection,
709
+ ...(object.payload === undefined
710
+ ? {}
711
+ : {
712
+ payload: boundarySafe(object.payload, 'DataSurface action payload'),
713
+ }),
714
+ ...(confirmationToken ? { confirmationToken } : {}),
715
+ };
716
+ assertRequestByteLimit(normalized, 'DataSurface action request');
717
+ return normalized;
718
+ }
719
+ function snapshotFrom(descriptor, input) {
720
+ return normalizeDataSurfaceSnapshot({
721
+ version: 1,
722
+ descriptor,
723
+ revision: input.revision,
724
+ state: input.state,
725
+ selection: input.selection ?? null,
726
+ });
727
+ }
728
+ function cloneSnapshot(snapshot) {
729
+ return normalizeDataSurfaceSnapshot(snapshot);
730
+ }
731
+ function cloneResult(result) {
732
+ return {
733
+ ok: result.ok,
734
+ commandId: result.commandId,
735
+ identity: cloneIdentity(result.identity),
736
+ ...(result.revision === undefined ? {} : { revision: result.revision }),
737
+ ...(result.snapshot ? { snapshot: cloneSnapshot(result.snapshot) } : {}),
738
+ ...(result.reason ? { reason: result.reason } : {}),
739
+ };
740
+ }
741
+ function sameIdentity(left, right) {
742
+ return identityKey(left) === identityKey(right);
743
+ }
744
+ function sameSnapshotContent(left, right) {
745
+ return snapshotContentSignature(left) === snapshotContentSignature(right);
746
+ }
747
+ function snapshotContentSignature(snapshot) {
748
+ return jsonSignature([snapshot.state, snapshot.selection]);
749
+ }
750
+ /** Create an isolated registry; applications choose how it is exposed. */
751
+ export function createDataSurfaceRegistry() {
752
+ const entries = new Map();
753
+ const listeners = new Set();
754
+ let sequence = 0;
755
+ const readSnapshot = (entry) => {
756
+ const raw = snapshotFrom(entry.descriptor, entry.registration.getSnapshot());
757
+ if (raw.revision < entry.lastRevision) {
758
+ throw new RangeError('DataSurface revision must be monotonic');
759
+ }
760
+ const exposed = entry.registration.redact
761
+ ? normalizeDataSurfaceSnapshot(entry.registration.redact(cloneSnapshot(raw)))
762
+ : raw;
763
+ if (!sameIdentity(exposed.descriptor.identity, entry.descriptor.identity) ||
764
+ exposed.revision !== raw.revision) {
765
+ throw new TypeError('DataSurface redaction cannot change identity or revision');
766
+ }
767
+ const contentSignature = snapshotContentSignature(raw);
768
+ const exposedContentSignature = snapshotContentSignature(exposed);
769
+ if (entry.lastSnapshotContentSignature !== undefined &&
770
+ raw.revision === entry.lastRevision &&
771
+ (entry.lastSnapshotContentSignature !== contentSignature ||
772
+ entry.lastExposedSnapshotContentSignature !== exposedContentSignature)) {
773
+ entry.commandBlockedAtRevision = raw.revision;
774
+ }
775
+ entry.lastRevision = raw.revision;
776
+ entry.lastSnapshotContentSignature = contentSignature;
777
+ entry.lastExposedSnapshotContentSignature = exposedContentSignature;
778
+ return { raw, exposed };
779
+ };
780
+ const emit = (type, entry, revision, command, result) => {
781
+ const event = {
782
+ type,
783
+ sequence: ++sequence,
784
+ identity: cloneIdentity(entry.descriptor.identity),
785
+ revision,
786
+ ...(command
787
+ ? { command: normalizeDataSurfaceVisibleCommand(command) }
788
+ : {}),
789
+ ...(result ? { result: cloneResult(result) } : {}),
790
+ };
791
+ for (const listener of listeners) {
792
+ try {
793
+ listener({
794
+ ...event,
795
+ identity: cloneIdentity(event.identity),
796
+ ...(event.command
797
+ ? { command: normalizeDataSurfaceVisibleCommand(event.command) }
798
+ : {}),
799
+ ...(event.result ? { result: cloneResult(event.result) } : {}),
800
+ });
801
+ }
802
+ catch {
803
+ // Subscribers are observational and cannot alter registry operations.
804
+ }
805
+ }
806
+ };
807
+ const cacheResult = (entry, command, result) => {
808
+ entry.replay.set(command.commandId, {
809
+ signature: jsonSignature(command),
810
+ result: cloneResult(result),
811
+ });
812
+ while (entry.replay.size > DATA_SURFACE_MAX_REPLAY_ENTRIES) {
813
+ const oldestCommandId = entry.replay.keys().next().value;
814
+ if (oldestCommandId === undefined)
815
+ break;
816
+ entry.replay.delete(oldestCommandId);
817
+ }
818
+ emit('command', entry, result.revision ?? entry.lastRevision, command, result);
819
+ return cloneResult(result);
820
+ };
821
+ const validationForIdentity = (identity) => entries.get(identityKey(identity));
822
+ const serializeCommand = async (entry, run) => {
823
+ const previous = entry.commandQueue;
824
+ let releaseQueue;
825
+ entry.commandQueue = new Promise((resolve) => {
826
+ releaseQueue = resolve;
827
+ });
828
+ await previous;
829
+ try {
830
+ return await run();
831
+ }
832
+ finally {
833
+ releaseQueue();
834
+ }
835
+ };
836
+ return {
837
+ register(registration) {
838
+ const descriptor = normalizeDataSurfaceDescriptor(registration.descriptor);
839
+ const key = identityKey(descriptor.identity);
840
+ if (entries.has(key)) {
841
+ throw new Error(`DataSurface identity is already registered: ${key}`);
842
+ }
843
+ const entry = {
844
+ key,
845
+ descriptor,
846
+ registration,
847
+ lastRevision: -1,
848
+ replay: new Map(),
849
+ commandQueue: Promise.resolve(),
850
+ };
851
+ entries.set(key, entry);
852
+ try {
853
+ const snapshot = readSnapshot(entry);
854
+ emit('registered', entry, snapshot.exposed.revision);
855
+ }
856
+ catch (error) {
857
+ entries.delete(key);
858
+ throw error;
859
+ }
860
+ return () => {
861
+ if (entries.get(key) !== entry)
862
+ return;
863
+ entries.delete(key);
864
+ emit('unregistered', entry, Math.max(0, entry.lastRevision));
865
+ };
866
+ },
867
+ unregister(identity) {
868
+ const normalized = normalizeIdentity(identity);
869
+ const entry = entries.get(identityKey(normalized));
870
+ if (!entry)
871
+ return;
872
+ entries.delete(entry.key);
873
+ emit('unregistered', entry, Math.max(0, entry.lastRevision));
874
+ },
875
+ list() {
876
+ return [...entries.values()]
877
+ .sort((left, right) => left.key.localeCompare(right.key))
878
+ .map((entry) => normalizeDataSurfaceDescriptor(entry.descriptor));
879
+ },
880
+ inspect(identity) {
881
+ const normalized = normalizeIdentity(identity);
882
+ const entry = entries.get(identityKey(normalized));
883
+ return entry ? cloneSnapshot(readSnapshot(entry).exposed) : undefined;
884
+ },
885
+ async execute(command) {
886
+ const normalized = normalizeDataSurfaceVisibleCommand(command);
887
+ const entry = entries.get(identityKey(normalized.identity));
888
+ const notFound = () => ({
889
+ ok: false,
890
+ commandId: normalized.commandId,
891
+ identity: cloneIdentity(normalized.identity),
892
+ reason: 'not_found',
893
+ });
894
+ if (!entry) {
895
+ return notFound();
896
+ }
897
+ return serializeCommand(entry, async () => {
898
+ if (entries.get(entry.key) !== entry) {
899
+ return notFound();
900
+ }
901
+ const signature = jsonSignature(normalized);
902
+ const replay = entry.replay.get(normalized.commandId);
903
+ if (replay) {
904
+ if (replay.signature === signature) {
905
+ entry.replay.delete(normalized.commandId);
906
+ entry.replay.set(normalized.commandId, replay);
907
+ return cloneResult(replay.result);
908
+ }
909
+ const snapshot = readSnapshot(entry).exposed;
910
+ const result = {
911
+ ok: false,
912
+ commandId: normalized.commandId,
913
+ identity: cloneIdentity(entry.descriptor.identity),
914
+ revision: snapshot.revision,
915
+ snapshot,
916
+ reason: 'idempotency_conflict',
917
+ };
918
+ emit('command', entry, snapshot.revision, normalized, result);
919
+ return cloneResult(result);
920
+ }
921
+ const before = readSnapshot(entry);
922
+ if (entry.commandBlockedAtRevision !== undefined &&
923
+ before.raw.revision <= entry.commandBlockedAtRevision) {
924
+ return cacheResult(entry, normalized, {
925
+ ok: false,
926
+ commandId: normalized.commandId,
927
+ identity: cloneIdentity(entry.descriptor.identity),
928
+ revision: before.exposed.revision,
929
+ snapshot: before.exposed,
930
+ reason: 'non_monotonic_revision',
931
+ });
932
+ }
933
+ entry.commandBlockedAtRevision = undefined;
934
+ if (before.raw.revision !== normalized.expectedRevision) {
935
+ return cacheResult(entry, normalized, {
936
+ ok: false,
937
+ commandId: normalized.commandId,
938
+ identity: cloneIdentity(entry.descriptor.identity),
939
+ revision: before.exposed.revision,
940
+ snapshot: before.exposed,
941
+ reason: 'stale_revision',
942
+ });
943
+ }
944
+ if (!entry.descriptor.controls.some((control) => control.id === normalized.controlId)) {
945
+ return cacheResult(entry, normalized, {
946
+ ok: false,
947
+ commandId: normalized.commandId,
948
+ identity: cloneIdentity(entry.descriptor.identity),
949
+ revision: before.exposed.revision,
950
+ snapshot: before.exposed,
951
+ reason: 'unsupported',
952
+ });
953
+ }
954
+ if (!entry.registration.execute) {
955
+ return cacheResult(entry, normalized, {
956
+ ok: false,
957
+ commandId: normalized.commandId,
958
+ identity: cloneIdentity(entry.descriptor.identity),
959
+ revision: before.exposed.revision,
960
+ snapshot: before.exposed,
961
+ reason: 'unsupported',
962
+ });
963
+ }
964
+ try {
965
+ const execution = await entry.registration.execute(normalizeDataSurfaceVisibleCommand(normalized));
966
+ if (entries.get(entry.key) !== entry)
967
+ return notFound();
968
+ const after = readSnapshot(entry);
969
+ if (sameSnapshotContent(before.raw, after.raw) === false &&
970
+ after.raw.revision <= before.raw.revision) {
971
+ entry.commandBlockedAtRevision = after.raw.revision;
972
+ return cacheResult(entry, normalized, {
973
+ ok: false,
974
+ commandId: normalized.commandId,
975
+ identity: cloneIdentity(entry.descriptor.identity),
976
+ revision: after.exposed.revision,
977
+ snapshot: after.exposed,
978
+ reason: 'non_monotonic_revision',
979
+ });
980
+ }
981
+ if (execution && execution.ok === false) {
982
+ return cacheResult(entry, normalized, {
983
+ ok: false,
984
+ commandId: normalized.commandId,
985
+ identity: cloneIdentity(entry.descriptor.identity),
986
+ revision: after.exposed.revision,
987
+ snapshot: after.exposed,
988
+ reason: 'denied',
989
+ });
990
+ }
991
+ return cacheResult(entry, normalized, {
992
+ ok: true,
993
+ commandId: normalized.commandId,
994
+ identity: cloneIdentity(entry.descriptor.identity),
995
+ revision: after.exposed.revision,
996
+ snapshot: after.exposed,
997
+ });
998
+ }
999
+ catch {
1000
+ if (entries.get(entry.key) !== entry)
1001
+ return notFound();
1002
+ let current;
1003
+ try {
1004
+ current = readSnapshot(entry);
1005
+ }
1006
+ catch {
1007
+ // A teardown can make snapshot retrieval fail after the handler.
1008
+ }
1009
+ if (current &&
1010
+ sameSnapshotContent(before.raw, current.raw) === false &&
1011
+ current.raw.revision <= before.raw.revision) {
1012
+ entry.commandBlockedAtRevision = current.raw.revision;
1013
+ return cacheResult(entry, normalized, {
1014
+ ok: false,
1015
+ commandId: normalized.commandId,
1016
+ identity: cloneIdentity(entry.descriptor.identity),
1017
+ revision: current.exposed.revision,
1018
+ snapshot: current.exposed,
1019
+ reason: 'non_monotonic_revision',
1020
+ });
1021
+ }
1022
+ return cacheResult(entry, normalized, {
1023
+ ok: false,
1024
+ commandId: normalized.commandId,
1025
+ identity: cloneIdentity(entry.descriptor.identity),
1026
+ ...(current
1027
+ ? {
1028
+ revision: current.exposed.revision,
1029
+ snapshot: current.exposed,
1030
+ }
1031
+ : {}),
1032
+ reason: 'execution_failed',
1033
+ });
1034
+ }
1035
+ });
1036
+ },
1037
+ validateQuery(request) {
1038
+ try {
1039
+ const normalized = normalizeDataSurfaceQueryRequest(request);
1040
+ const entry = validationForIdentity(normalized.identity);
1041
+ if (!entry)
1042
+ return { ok: false, reason: 'not_found' };
1043
+ if (!entry.descriptor.query.modes.includes(normalized.kind)) {
1044
+ return { ok: false, reason: 'unsupported' };
1045
+ }
1046
+ if (canonicalQueryByteLength(normalized) >
1047
+ entry.descriptor.limits.maxQueryBytes) {
1048
+ return { ok: false, reason: 'limit_exceeded' };
1049
+ }
1050
+ if ('limit' in normalized &&
1051
+ normalized.limit > entry.descriptor.limits.maxQueryRows) {
1052
+ return { ok: false, reason: 'limit_exceeded' };
1053
+ }
1054
+ if (normalized.kind === 'rows' && normalized.projection) {
1055
+ if (normalized.projection.some((columnId) => !entry.descriptor.query.projectableColumnIds.includes(columnId))) {
1056
+ return { ok: false, reason: 'projection_not_allowed' };
1057
+ }
1058
+ }
1059
+ if (normalized.kind === 'facets' &&
1060
+ !entry.descriptor.query.projectableColumnIds.includes(normalized.columnId)) {
1061
+ return { ok: false, reason: 'projection_not_allowed' };
1062
+ }
1063
+ return { ok: true };
1064
+ }
1065
+ catch {
1066
+ return { ok: false, reason: 'invalid_request' };
1067
+ }
1068
+ },
1069
+ validateAction(request) {
1070
+ try {
1071
+ const normalized = normalizeDataSurfaceActionRequest(request);
1072
+ const entry = validationForIdentity(normalized.identity);
1073
+ if (!entry)
1074
+ return { ok: false, reason: 'not_found' };
1075
+ const action = entry.descriptor.actions.find((candidate) => candidate.id === normalized.actionId);
1076
+ if (!action)
1077
+ return { ok: false, reason: 'unsupported' };
1078
+ if (!action.selectionScopes.includes(normalized.selection.scope)) {
1079
+ return { ok: false, reason: 'selection_not_supported' };
1080
+ }
1081
+ if (normalized.selection.scope === 'explicit-ids' &&
1082
+ normalized.selection.rowIds.length >
1083
+ entry.descriptor.limits.maxSelectionSize) {
1084
+ return { ok: false, reason: 'limit_exceeded' };
1085
+ }
1086
+ if (normalized.phase === 'apply' &&
1087
+ action.requiresConfirmation !== false &&
1088
+ !normalized.confirmationToken) {
1089
+ return { ok: false, reason: 'confirmation_required' };
1090
+ }
1091
+ return { ok: true };
1092
+ }
1093
+ catch {
1094
+ return { ok: false, reason: 'invalid_request' };
1095
+ }
1096
+ },
1097
+ subscribe(listener) {
1098
+ listeners.add(listener);
1099
+ return () => listeners.delete(listener);
1100
+ },
1101
+ };
1102
+ }