@happyvertical/smrt-ui 0.42.4 → 0.42.6

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,675 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createDataSurfaceRegistry, DATA_SURFACE_MAX_REPLAY_ENTRIES, DATA_SURFACE_MAX_REQUEST_BYTES, normalizeDataSurfaceActionRequest, normalizeDataSurfaceQueryRequest, normalizeDataSurfaceVisibleCommand, } from '../data-surface.js';
3
+ const identity = {
4
+ surfaceId: 'content-library',
5
+ kind: 'table',
6
+ subject: { type: 'site', id: 'docs' },
7
+ };
8
+ function descriptor(overrides = {}) {
9
+ return {
10
+ version: 1,
11
+ identity,
12
+ schemaVersion: 1,
13
+ label: 'Content library',
14
+ rowKey: 'id',
15
+ columns: [
16
+ { id: 'id', label: 'ID', capabilities: ['read', 'project'] },
17
+ {
18
+ id: 'title',
19
+ label: 'Title',
20
+ capabilities: ['read', 'search', 'filter', 'sort', 'project'],
21
+ },
22
+ {
23
+ id: 'internal',
24
+ label: 'Internal',
25
+ sensitivity: 'sensitive',
26
+ capabilities: [],
27
+ },
28
+ ],
29
+ query: {
30
+ modes: ['rows', 'count', 'facets'],
31
+ projectableColumnIds: ['id', 'title'],
32
+ },
33
+ controls: [{ id: 'set-search', label: 'Search' }],
34
+ actions: [
35
+ {
36
+ id: 'archive',
37
+ label: 'Archive',
38
+ selectionScopes: ['explicit-ids', 'all-matching'],
39
+ requiresConfirmation: true,
40
+ },
41
+ ],
42
+ limits: { maxQueryRows: 50, maxQueryBytes: 10_000, maxSelectionSize: 3 },
43
+ ...overrides,
44
+ };
45
+ }
46
+ function command(overrides = {}) {
47
+ return {
48
+ version: 1,
49
+ commandId: 'search-1',
50
+ identity,
51
+ expectedRevision: 3,
52
+ controlId: 'set-search',
53
+ payload: { search: 'Grace' },
54
+ ...overrides,
55
+ };
56
+ }
57
+ function registerFixture(options = {}) {
58
+ let revision = 3;
59
+ let state = { search: 'Ada', internal: 'not-for-discovery' };
60
+ const execute = vi.fn((next) => {
61
+ state = {
62
+ ...state,
63
+ search: next.payload &&
64
+ typeof next.payload === 'object' &&
65
+ !Array.isArray(next.payload)
66
+ ? String(next.payload.search ?? '')
67
+ : '',
68
+ };
69
+ if (options.advance !== false)
70
+ revision += 1;
71
+ });
72
+ const registry = createDataSurfaceRegistry();
73
+ const unregister = registry.register({
74
+ descriptor: descriptor(),
75
+ getSnapshot: () => ({ revision, state }),
76
+ execute,
77
+ ...(options.redact
78
+ ? {
79
+ redact: (snapshot) => ({
80
+ ...snapshot,
81
+ state: { search: snapshot.state.search ?? '' },
82
+ }),
83
+ }
84
+ : {}),
85
+ });
86
+ return { registry, unregister, execute };
87
+ }
88
+ describe('data surface registry', () => {
89
+ it('aligns bounded query result shapes with each query kind', () => {
90
+ const results = [
91
+ {
92
+ version: 1,
93
+ requestId: 'rows-1',
94
+ identity,
95
+ revision: 3,
96
+ kind: 'rows',
97
+ rowKey: 'id',
98
+ rows: [{ id: 'a' }],
99
+ hasMore: false,
100
+ truncated: false,
101
+ },
102
+ {
103
+ version: 1,
104
+ requestId: 'count-1',
105
+ identity,
106
+ revision: 3,
107
+ kind: 'count',
108
+ count: 1,
109
+ },
110
+ {
111
+ version: 1,
112
+ requestId: 'facets-1',
113
+ identity,
114
+ revision: 3,
115
+ kind: 'facets',
116
+ columnId: 'title',
117
+ facets: [{ value: 'Guide', count: 1 }],
118
+ truncated: false,
119
+ },
120
+ ];
121
+ expect(results.map((result) => result.kind)).toEqual([
122
+ 'rows',
123
+ 'count',
124
+ 'facets',
125
+ ]);
126
+ });
127
+ it('discovers defensive serializable descriptors and snapshots', () => {
128
+ const { registry } = registerFixture();
129
+ const [listed] = registry.list();
130
+ expect(listed).toMatchObject({
131
+ identity,
132
+ rowKey: 'id',
133
+ query: { projectableColumnIds: ['id', 'title'] },
134
+ });
135
+ listed.label = 'mutated by a consumer';
136
+ expect(registry.list()[0]?.label).toBe('Content library');
137
+ const snapshot = registry.inspect(identity);
138
+ expect(snapshot).toMatchObject({
139
+ revision: 3,
140
+ state: { search: 'Ada', internal: 'not-for-discovery' },
141
+ selection: null,
142
+ });
143
+ expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot);
144
+ });
145
+ it('rejects duplicate mounted identities rather than replacing them', () => {
146
+ const { registry } = registerFixture();
147
+ expect(() => registry.register({
148
+ descriptor: descriptor(),
149
+ getSnapshot: () => ({ revision: 0, state: {} }),
150
+ })).toThrow(JSON.stringify(['table', 'content-library', ['site', 'docs']]));
151
+ });
152
+ it('keeps identities with delimiter-like components distinct', () => {
153
+ const firstIdentity = {
154
+ kind: 'table',
155
+ surfaceId: 'x',
156
+ subject: { type: 'y', id: 'z\u0000w' },
157
+ };
158
+ const secondIdentity = {
159
+ kind: 'table',
160
+ surfaceId: 'x\u0000y',
161
+ subject: { type: 'z', id: 'w' },
162
+ };
163
+ const registry = createDataSurfaceRegistry();
164
+ registry.register({
165
+ descriptor: descriptor({ identity: firstIdentity }),
166
+ getSnapshot: () => ({ revision: 0, state: {} }),
167
+ });
168
+ registry.register({
169
+ descriptor: descriptor({ identity: secondIdentity }),
170
+ getSnapshot: () => ({ revision: 0, state: {} }),
171
+ });
172
+ expect(registry.list()).toHaveLength(2);
173
+ registry.unregister(secondIdentity);
174
+ expect(registry.inspect(firstIdentity)?.revision).toBe(0);
175
+ expect(registry.inspect(secondIdentity)).toBeUndefined();
176
+ });
177
+ it('applies the host-owned redaction boundary before inspect results escape', () => {
178
+ const { registry } = registerFixture({ redact: true });
179
+ expect(registry.inspect(identity)?.state).toEqual({ search: 'Ada' });
180
+ });
181
+ it('rejects authority and SQL keys from default and redacted snapshots', () => {
182
+ const registry = createDataSurfaceRegistry();
183
+ expect(() => registry.register({
184
+ descriptor: descriptor(),
185
+ getSnapshot: () => ({ revision: 0, state: { tenantId: 'other' } }),
186
+ })).toThrow('authority or SQL');
187
+ expect(() => registry.register({
188
+ descriptor: descriptor({
189
+ identity: { ...identity, surfaceId: 'tenant-context' },
190
+ }),
191
+ getSnapshot: () => ({
192
+ revision: 0,
193
+ state: { tenant: { id: 'other' } },
194
+ }),
195
+ })).toThrow('authority or SQL');
196
+ expect(() => registry.register({
197
+ descriptor: descriptor({
198
+ identity: { ...identity, surfaceId: 'authorization-header' },
199
+ }),
200
+ getSnapshot: () => ({
201
+ revision: 0,
202
+ state: { authorizationHeader: 'Bearer credential' },
203
+ }),
204
+ })).toThrow('authority or SQL');
205
+ expect(() => registry.register({
206
+ descriptor: descriptor({
207
+ identity: { ...identity, surfaceId: 'authorization-token' },
208
+ }),
209
+ getSnapshot: () => ({
210
+ revision: 0,
211
+ state: { authToken: 'Bearer credential' },
212
+ }),
213
+ })).toThrow('authority or SQL');
214
+ for (const [surfaceId, state] of [
215
+ ['bare-token', { token: 'credential' }],
216
+ ['bare-bearer', { bearer: 'credential' }],
217
+ ]) {
218
+ expect(() => registry.register({
219
+ descriptor: descriptor({
220
+ identity: { ...identity, surfaceId },
221
+ }),
222
+ getSnapshot: () => ({ revision: 0, state }),
223
+ })).toThrow('authority or SQL');
224
+ }
225
+ expect(() => registry.register({
226
+ descriptor: descriptor(),
227
+ getSnapshot: () => ({ revision: 0, state: {} }),
228
+ redact: (snapshot) => ({ ...snapshot, state: { sql: 'select 1' } }),
229
+ })).toThrow('authority or SQL');
230
+ });
231
+ it('rejects unsupported and stale commands before invoking the surface', async () => {
232
+ const { registry, execute } = registerFixture();
233
+ await expect(registry.execute(command({ controlId: 'delete-everything' }))).resolves.toMatchObject({ ok: false, reason: 'unsupported' });
234
+ await expect(registry.execute(command({ commandId: 'stale', expectedRevision: 2 }))).resolves.toMatchObject({
235
+ ok: false,
236
+ reason: 'stale_revision',
237
+ revision: 3,
238
+ });
239
+ expect(execute).not.toHaveBeenCalled();
240
+ });
241
+ it('rejects commands that unmount and replace their surface', async () => {
242
+ let unregister = () => undefined;
243
+ const registry = createDataSurfaceRegistry();
244
+ unregister = registry.register({
245
+ descriptor: descriptor(),
246
+ getSnapshot: () => ({ revision: 3, state: { search: 'Ada' } }),
247
+ execute: () => {
248
+ unregister();
249
+ registry.register({
250
+ descriptor: descriptor(),
251
+ getSnapshot: () => ({ revision: 9, state: { search: 'Grace' } }),
252
+ });
253
+ },
254
+ });
255
+ await expect(registry.execute(command())).resolves.toMatchObject({
256
+ ok: false,
257
+ reason: 'not_found',
258
+ });
259
+ expect(registry.inspect(identity)).toMatchObject({
260
+ revision: 9,
261
+ state: { search: 'Grace' },
262
+ });
263
+ });
264
+ it('replays a command idempotently and rejects conflicting reuse of its id', async () => {
265
+ const { registry, execute } = registerFixture();
266
+ const initial = command();
267
+ const first = await registry.execute(initial);
268
+ const replay = await registry.execute(initial);
269
+ const conflict = await registry.execute(command({ payload: { search: 'Ada' } }));
270
+ expect(first).toMatchObject({ ok: true, revision: 4 });
271
+ expect(replay).toEqual(first);
272
+ expect(conflict).toMatchObject({
273
+ ok: false,
274
+ reason: 'idempotency_conflict',
275
+ });
276
+ expect(execute).toHaveBeenCalledTimes(1);
277
+ });
278
+ it('bounds the LRU replay cache for long-lived mounted surfaces', async () => {
279
+ let revision = 0;
280
+ const execute = vi.fn(() => {
281
+ revision += 1;
282
+ });
283
+ const registry = createDataSurfaceRegistry();
284
+ registry.register({
285
+ descriptor: descriptor(),
286
+ getSnapshot: () => ({ revision, state: {} }),
287
+ execute,
288
+ });
289
+ for (let index = 0; index < DATA_SURFACE_MAX_REPLAY_ENTRIES; index += 1) {
290
+ await expect(registry.execute(command({
291
+ commandId: `command-${index}`,
292
+ expectedRevision: index,
293
+ }))).resolves.toMatchObject({ ok: true, revision: index + 1 });
294
+ }
295
+ await expect(registry.execute(command({ commandId: 'command-0', expectedRevision: 0 }))).resolves.toMatchObject({ ok: true, revision: 1 });
296
+ await expect(registry.execute(command({
297
+ commandId: `command-${DATA_SURFACE_MAX_REPLAY_ENTRIES}`,
298
+ expectedRevision: DATA_SURFACE_MAX_REPLAY_ENTRIES,
299
+ }))).resolves.toMatchObject({
300
+ ok: true,
301
+ revision: DATA_SURFACE_MAX_REPLAY_ENTRIES + 1,
302
+ });
303
+ await expect(registry.execute(command({ commandId: 'command-1', expectedRevision: 1 }))).resolves.toMatchObject({
304
+ ok: false,
305
+ reason: 'stale_revision',
306
+ revision: DATA_SURFACE_MAX_REPLAY_ENTRIES + 1,
307
+ });
308
+ await expect(registry.execute(command({ commandId: 'command-0', expectedRevision: 0 }))).resolves.toMatchObject({ ok: true, revision: 1 });
309
+ expect(execute).toHaveBeenCalledTimes(DATA_SURFACE_MAX_REPLAY_ENTRIES + 1);
310
+ });
311
+ it('serializes concurrent commands so only one can execute at a revision', async () => {
312
+ let revision = 3;
313
+ let releaseFirst;
314
+ const firstExecution = new Promise((resolve) => {
315
+ releaseFirst = resolve;
316
+ });
317
+ const execute = vi.fn(async () => {
318
+ await firstExecution;
319
+ revision += 1;
320
+ });
321
+ const registry = createDataSurfaceRegistry();
322
+ registry.register({
323
+ descriptor: descriptor(),
324
+ getSnapshot: () => ({ revision, state: {} }),
325
+ execute,
326
+ });
327
+ const first = registry.execute(command({ commandId: 'first' }));
328
+ const second = registry.execute(command({ commandId: 'second' }));
329
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
330
+ releaseFirst();
331
+ await expect(first).resolves.toMatchObject({ ok: true, revision: 4 });
332
+ await expect(second).resolves.toMatchObject({
333
+ ok: false,
334
+ reason: 'stale_revision',
335
+ revision: 4,
336
+ });
337
+ expect(execute).toHaveBeenCalledTimes(1);
338
+ });
339
+ it('rejects in-flight and queued commands after their surface unregisters', async () => {
340
+ let revision = 3;
341
+ let releaseFirst;
342
+ const firstExecution = new Promise((resolve) => {
343
+ releaseFirst = resolve;
344
+ });
345
+ const execute = vi.fn(async () => {
346
+ await firstExecution;
347
+ revision += 1;
348
+ });
349
+ const registry = createDataSurfaceRegistry();
350
+ const unregister = registry.register({
351
+ descriptor: descriptor(),
352
+ getSnapshot: () => ({ revision, state: {} }),
353
+ execute,
354
+ });
355
+ const first = registry.execute(command({ commandId: 'first' }));
356
+ const queued = registry.execute(command({ commandId: 'queued' }));
357
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
358
+ unregister();
359
+ releaseFirst();
360
+ await expect(first).resolves.toMatchObject({
361
+ ok: false,
362
+ commandId: 'first',
363
+ reason: 'not_found',
364
+ });
365
+ await expect(queued).resolves.toMatchObject({
366
+ ok: false,
367
+ commandId: 'queued',
368
+ reason: 'not_found',
369
+ });
370
+ expect(execute).toHaveBeenCalledTimes(1);
371
+ });
372
+ it('returns execution_failed when handler teardown also breaks snapshots', async () => {
373
+ let tornDown = false;
374
+ const registry = createDataSurfaceRegistry();
375
+ registry.register({
376
+ descriptor: descriptor(),
377
+ getSnapshot: () => {
378
+ if (tornDown)
379
+ throw new Error('renderer is gone');
380
+ return { revision: 3, state: {} };
381
+ },
382
+ execute: () => {
383
+ tornDown = true;
384
+ throw new Error('handler failed');
385
+ },
386
+ });
387
+ await expect(registry.execute(command())).resolves.toMatchObject({
388
+ ok: false,
389
+ commandId: 'search-1',
390
+ reason: 'execution_failed',
391
+ });
392
+ });
393
+ it('quarantines a changed snapshot when its handler throws before revision advances', async () => {
394
+ let state = { search: 'Ada' };
395
+ const execute = vi.fn(() => {
396
+ state = { search: 'Grace' };
397
+ throw new Error('handler failed');
398
+ });
399
+ const registry = createDataSurfaceRegistry();
400
+ registry.register({
401
+ descriptor: descriptor(),
402
+ getSnapshot: () => ({ revision: 3, state }),
403
+ execute,
404
+ });
405
+ await expect(registry.execute(command())).resolves.toMatchObject({
406
+ ok: false,
407
+ reason: 'non_monotonic_revision',
408
+ });
409
+ await expect(registry.execute(command({ commandId: 'blocked-after-failure' }))).resolves.toMatchObject({
410
+ ok: false,
411
+ reason: 'non_monotonic_revision',
412
+ });
413
+ expect(execute).toHaveBeenCalledTimes(1);
414
+ });
415
+ it('quarantines a changed snapshot until its renderer advances revision', async () => {
416
+ let revision = 3;
417
+ let state = { search: 'Ada' };
418
+ let advance = false;
419
+ const execute = vi.fn(() => {
420
+ state = { search: 'Grace' };
421
+ if (advance)
422
+ revision += 1;
423
+ });
424
+ const registry = createDataSurfaceRegistry();
425
+ registry.register({
426
+ descriptor: descriptor(),
427
+ getSnapshot: () => ({ revision, state }),
428
+ execute,
429
+ });
430
+ await expect(registry.execute(command())).resolves.toMatchObject({
431
+ ok: false,
432
+ reason: 'non_monotonic_revision',
433
+ });
434
+ await expect(registry.execute(command({ commandId: 'blocked-at-revision-3' }))).resolves.toMatchObject({
435
+ ok: false,
436
+ reason: 'non_monotonic_revision',
437
+ });
438
+ expect(execute).toHaveBeenCalledTimes(1);
439
+ revision = 4;
440
+ advance = true;
441
+ await expect(registry.execute(command({
442
+ commandId: 'after-refresh',
443
+ expectedRevision: 4,
444
+ }))).resolves.toMatchObject({ ok: true, revision: 5 });
445
+ expect(execute).toHaveBeenCalledTimes(2);
446
+ });
447
+ it('quarantines host-driven snapshot changes until revision advances', async () => {
448
+ let revision = 3;
449
+ const state = { search: 'Ada' };
450
+ const execute = vi.fn(() => {
451
+ state.search = 'Lin';
452
+ revision += 1;
453
+ });
454
+ const registry = createDataSurfaceRegistry();
455
+ registry.register({
456
+ descriptor: descriptor(),
457
+ getSnapshot: () => ({ revision, state }),
458
+ execute,
459
+ });
460
+ state.search = 'Grace';
461
+ expect(registry.inspect(identity)?.state).toEqual({ search: 'Grace' });
462
+ await expect(registry.execute(command())).resolves.toMatchObject({
463
+ ok: false,
464
+ reason: 'non_monotonic_revision',
465
+ });
466
+ expect(execute).not.toHaveBeenCalled();
467
+ revision = 4;
468
+ await expect(registry.execute(command({ commandId: 'after-host-refresh', expectedRevision: 4 }))).resolves.toMatchObject({ ok: true, revision: 5 });
469
+ expect(execute).toHaveBeenCalledTimes(1);
470
+ });
471
+ it('quarantines redacted snapshot changes until revision advances', async () => {
472
+ let audience = 'owner';
473
+ const execute = vi.fn();
474
+ const registry = createDataSurfaceRegistry();
475
+ registry.register({
476
+ descriptor: descriptor(),
477
+ getSnapshot: () => ({ revision: 3, state: { search: 'Ada' } }),
478
+ execute,
479
+ redact: (snapshot) => ({
480
+ ...snapshot,
481
+ state: { search: audience },
482
+ }),
483
+ });
484
+ audience = 'viewer';
485
+ expect(registry.inspect(identity)?.state).toEqual({ search: 'viewer' });
486
+ await expect(registry.execute(command())).resolves.toMatchObject({
487
+ ok: false,
488
+ reason: 'non_monotonic_revision',
489
+ });
490
+ expect(execute).not.toHaveBeenCalled();
491
+ });
492
+ it('emits monotonic sequence events and removes unregistered surfaces', async () => {
493
+ const { registry, unregister } = registerFixture();
494
+ const events = [];
495
+ const unsubscribe = registry.subscribe((event) => events.push(event));
496
+ await registry.execute(command());
497
+ unregister();
498
+ unsubscribe();
499
+ expect(events.map(({ type, sequence, revision }) => ({
500
+ type,
501
+ sequence,
502
+ revision,
503
+ }))).toEqual([
504
+ { type: 'command', sequence: 2, revision: 4 },
505
+ { type: 'unregistered', sequence: 3, revision: 4 },
506
+ ]);
507
+ expect(registry.inspect(identity)).toBeUndefined();
508
+ expect(registry.list()).toEqual([]);
509
+ });
510
+ it('isolates subscriber failures from registry operations', async () => {
511
+ const { registry, execute } = registerFixture();
512
+ registry.subscribe(() => {
513
+ throw new Error('transport failed');
514
+ });
515
+ await expect(registry.execute(command())).resolves.toMatchObject({
516
+ ok: true,
517
+ revision: 4,
518
+ });
519
+ expect(execute).toHaveBeenCalledTimes(1);
520
+ const secondIdentity = { ...identity, surfaceId: 'secondary-surface' };
521
+ expect(() => registry.register({
522
+ descriptor: descriptor({ identity: secondIdentity }),
523
+ getSnapshot: () => ({ revision: 0, state: {} }),
524
+ })).not.toThrow();
525
+ expect(() => registry.unregister(secondIdentity)).not.toThrow();
526
+ });
527
+ it('validates bounded query and preview/apply action envelopes without executing them', () => {
528
+ const { registry } = registerFixture();
529
+ expect(registry.validateQuery({
530
+ version: 1,
531
+ requestId: 'rows-1',
532
+ identity,
533
+ kind: 'rows',
534
+ limit: 25,
535
+ projection: ['title', 'id'],
536
+ })).toEqual({ ok: true });
537
+ expect(registry.validateQuery({
538
+ version: 1,
539
+ requestId: 'rows-too-large',
540
+ identity,
541
+ kind: 'rows',
542
+ limit: 51,
543
+ })).toEqual({ ok: false, reason: 'limit_exceeded' });
544
+ expect(registry.validateQuery({
545
+ version: 1,
546
+ requestId: 'rows-hidden',
547
+ identity,
548
+ kind: 'rows',
549
+ limit: 1,
550
+ projection: ['internal'],
551
+ })).toEqual({ ok: false, reason: 'projection_not_allowed' });
552
+ expect(registry.validateAction({
553
+ version: 1,
554
+ requestId: 'archive-preview',
555
+ identity,
556
+ actionId: 'archive',
557
+ phase: 'preview',
558
+ selection: { scope: 'explicit-ids', rowIds: ['b', 'a', 'a'] },
559
+ })).toEqual({ ok: true });
560
+ expect(registry.validateAction({
561
+ version: 1,
562
+ requestId: 'archive-apply',
563
+ identity,
564
+ actionId: 'archive',
565
+ phase: 'apply',
566
+ selection: { scope: 'explicit-ids', rowIds: ['a'] },
567
+ })).toEqual({ ok: false, reason: 'confirmation_required' });
568
+ });
569
+ it('enforces the descriptor query-byte limit using the normalized UTF-8 envelope', () => {
570
+ const request = {
571
+ version: 1,
572
+ requestId: 'unicode-cursor',
573
+ identity,
574
+ kind: 'rows',
575
+ limit: 1,
576
+ cursor: 'é',
577
+ };
578
+ const maxQueryBytes = new TextEncoder().encode(JSON.stringify(normalizeDataSurfaceQueryRequest(request))).byteLength;
579
+ const registry = createDataSurfaceRegistry();
580
+ registry.register({
581
+ descriptor: descriptor({
582
+ limits: { maxQueryRows: 50, maxQueryBytes, maxSelectionSize: 3 },
583
+ }),
584
+ getSnapshot: () => ({ revision: 0, state: {} }),
585
+ });
586
+ expect(registry.validateQuery(request)).toEqual({ ok: true });
587
+ expect(registry.validateQuery({ ...request, cursor: 'éé' })).toEqual({
588
+ ok: false,
589
+ reason: 'limit_exceeded',
590
+ });
591
+ });
592
+ it('rejects SQL and authority values at the browser-contract boundary', () => {
593
+ expect(() => normalizeDataSurfaceVisibleCommand(command({ payload: { sql: 'select * from content' } }))).toThrow('authority or SQL');
594
+ expect(() => normalizeDataSurfaceVisibleCommand(command({ payload: { tenantId: 'other-tenant' } }))).toThrow('authority or SQL');
595
+ });
596
+ it('rejects parsed prototype keys at every browser-contract boundary', () => {
597
+ const prototypePayload = JSON.parse('{"__proto__":{"tenantId":"other"}}');
598
+ expect(() => normalizeDataSurfaceVisibleCommand(command({ payload: prototypePayload }))).toThrow('prototype key');
599
+ expect(() => normalizeDataSurfaceActionRequest({
600
+ version: 1,
601
+ requestId: 'prototype-action',
602
+ identity,
603
+ actionId: 'archive',
604
+ phase: 'preview',
605
+ selection: { scope: 'current-page' },
606
+ payload: prototypePayload,
607
+ })).toThrow('prototype key');
608
+ const registry = createDataSurfaceRegistry();
609
+ expect(() => registry.register({
610
+ descriptor: descriptor(),
611
+ getSnapshot: () => ({
612
+ revision: 0,
613
+ state: JSON.parse('{"__proto__":{"tenantId":"other"}}'),
614
+ }),
615
+ })).toThrow('prototype key');
616
+ });
617
+ it('bounds command and action envelopes before dispatch', () => {
618
+ const oversized = 'x'.repeat(DATA_SURFACE_MAX_REQUEST_BYTES);
619
+ expect(() => normalizeDataSurfaceVisibleCommand(command({ payload: { value: oversized } }))).toThrow('UTF-8 bytes');
620
+ expect(() => normalizeDataSurfaceActionRequest({
621
+ version: 1,
622
+ requestId: 'oversized-action',
623
+ identity,
624
+ actionId: 'archive',
625
+ phase: 'preview',
626
+ selection: { scope: 'current-page' },
627
+ payload: { value: oversized },
628
+ })).toThrow('UTF-8 bytes');
629
+ expect(() => normalizeDataSurfaceActionRequest({
630
+ version: 1,
631
+ requestId: 'many-row-ids',
632
+ identity,
633
+ actionId: 'archive',
634
+ phase: 'preview',
635
+ selection: {
636
+ scope: 'explicit-ids',
637
+ rowIds: Array.from({ length: 1_001 }, () => 'duplicate'),
638
+ },
639
+ })).toThrow('more than 1000 row ids');
640
+ });
641
+ it('bounds query content during structural validation', () => {
642
+ const stringify = vi.spyOn(JSON, 'stringify');
643
+ try {
644
+ expect(() => normalizeDataSurfaceQueryRequest({
645
+ version: 1,
646
+ requestId: 'oversized-projection',
647
+ identity,
648
+ kind: 'rows',
649
+ limit: 1,
650
+ projection: ['x'.repeat(DATA_SURFACE_MAX_REQUEST_BYTES)],
651
+ })).toThrow('DataSurface query request cannot exceed');
652
+ expect(stringify).not.toHaveBeenCalled();
653
+ }
654
+ finally {
655
+ stringify.mockRestore();
656
+ }
657
+ });
658
+ it('accepts a query whose canonical envelope is exactly at the byte limit', () => {
659
+ const request = {
660
+ version: 1,
661
+ requestId: 'exact-limit',
662
+ identity,
663
+ kind: 'rows',
664
+ limit: 1,
665
+ projection: [''],
666
+ };
667
+ const baseLength = new TextEncoder().encode(JSON.stringify(request)).byteLength;
668
+ const exactLimit = {
669
+ ...request,
670
+ projection: ['x'.repeat(DATA_SURFACE_MAX_REQUEST_BYTES - baseLength)],
671
+ };
672
+ expect(new TextEncoder().encode(JSON.stringify(exactLimit)).byteLength).toBe(DATA_SURFACE_MAX_REQUEST_BYTES);
673
+ expect(normalizeDataSurfaceQueryRequest(exactLimit)).toEqual(exactLimit);
674
+ });
675
+ });