@fias/arche-sdk 2.3.0 → 2.11.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 (54) hide show
  1. package/dist/bridge.d.ts +76 -1
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +219 -6
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/bridge.test.js +155 -6
  6. package/dist/bridge.test.js.map +1 -1
  7. package/dist/coverage-fills.test.js +26 -32
  8. package/dist/coverage-fills.test.js.map +1 -1
  9. package/dist/entity-ops.d.ts +55 -0
  10. package/dist/entity-ops.d.ts.map +1 -0
  11. package/dist/entity-ops.js +48 -0
  12. package/dist/entity-ops.js.map +1 -0
  13. package/dist/fias.d.ts +11 -1
  14. package/dist/fias.d.ts.map +1 -1
  15. package/dist/fias.js +39 -12
  16. package/dist/fias.js.map +1 -1
  17. package/dist/generated/permissions.d.ts +7 -1
  18. package/dist/generated/permissions.d.ts.map +1 -1
  19. package/dist/generated/permissions.js +35 -2
  20. package/dist/generated/permissions.js.map +1 -1
  21. package/dist/generated/surfaces.d.ts +424 -0
  22. package/dist/generated/surfaces.d.ts.map +1 -1
  23. package/dist/generated/surfaces.js +97 -1
  24. package/dist/generated/surfaces.js.map +1 -1
  25. package/dist/hooks.d.ts +228 -13
  26. package/dist/hooks.d.ts.map +1 -1
  27. package/dist/hooks.js +527 -104
  28. package/dist/hooks.js.map +1 -1
  29. package/dist/hooks.test.js +314 -247
  30. package/dist/hooks.test.js.map +1 -1
  31. package/dist/index.d.ts +4 -4
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +12 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/index.mjs +982 -115
  36. package/dist/protocol.d.ts +7 -0
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +7 -0
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/provider.d.ts +50 -1
  41. package/dist/provider.d.ts.map +1 -1
  42. package/dist/provider.js +187 -2
  43. package/dist/provider.js.map +1 -1
  44. package/dist/provider.render.test.d.ts +17 -0
  45. package/dist/provider.render.test.d.ts.map +1 -0
  46. package/dist/provider.render.test.js +191 -0
  47. package/dist/provider.render.test.js.map +1 -0
  48. package/dist/provider.test.js +64 -8
  49. package/dist/provider.test.js.map +1 -1
  50. package/dist/types.d.ts +373 -27
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +2 -1
  53. package/templates/default/AGENTS.md +321 -34
  54. package/templates/default/CLAUDE.md +321 -34
@@ -41,6 +41,18 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  const theme_1 = require("./theme");
44
+ const entity_ops_1 = require("./entity-ops");
45
+ /**
46
+ * Expected `bridge.request` args for a verb call migrated onto `entity_invoke`
47
+ * (ADR-012 Phase L, Job 3). Spread into `toHaveBeenCalledWith(...)`.
48
+ */
49
+ function entityOp(entityId, op, args = {}) {
50
+ return ['entity_invoke', { entityId, payload: { op, ...args } }];
51
+ }
52
+ /** Wrap a value the way the server's `entity_invoke` envelope does. */
53
+ function output(value) {
54
+ return { output: value };
55
+ }
44
56
  // Build a mock bridge that tracks calls
45
57
  function createMockBridge() {
46
58
  return {
@@ -57,6 +69,10 @@ function createMockBridge() {
57
69
  onThemeUpdate: jest.fn().mockReturnValue(() => { }),
58
70
  onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
59
71
  onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
72
+ registerPreviewState: jest.fn().mockReturnValue(() => { }),
73
+ onSubscriptionEvent: jest.fn().mockReturnValue(() => { }),
74
+ onSubscriptionEnded: jest.fn().mockReturnValue(() => { }),
75
+ releaseSubscription: jest.fn(),
60
76
  attach: jest.fn(),
61
77
  destroy: jest.fn(),
62
78
  };
@@ -124,6 +140,54 @@ describe('SDK hooks', () => {
124
140
  const { useFiasUser } = require('./hooks');
125
141
  expect(() => useFiasUser()).toThrow('useBridge must be used within a <FiasProvider>');
126
142
  });
143
+ it('records the error when get_user rejects (no silent swallow)', async () => {
144
+ const { useFiasUser } = require('./hooks');
145
+ mockBridge.request.mockRejectedValue(new Error('Permission denied: user:profile:read not granted'));
146
+ const result = useFiasUser();
147
+ expect(result).toBeNull();
148
+ // Let the rejection handler run.
149
+ await Promise.resolve();
150
+ await Promise.resolve();
151
+ // The setter (shared between the user/error useState slots) is called
152
+ // with the Error — proving the rejection path ran instead of being
153
+ // swallowed into a permanent null.
154
+ expect(mockSetState).toHaveBeenCalledWith(expect.any(Error));
155
+ const errorArg = mockSetState.mock.calls
156
+ .map((c) => c[0])
157
+ .find((a) => a instanceof Error);
158
+ expect(errorArg.message).toContain('user:profile:read');
159
+ });
160
+ it('throws the error during render so it hits an error boundary', () => {
161
+ const react = require('react');
162
+ // user slot → null; error slot → a populated error.
163
+ react.useState
164
+ .mockReturnValueOnce([null, mockSetState])
165
+ .mockReturnValueOnce([
166
+ new Error('Permission denied: user:profile:read not granted'),
167
+ mockSetState,
168
+ ]);
169
+ const { useFiasUser } = require('./hooks');
170
+ expect(() => useFiasUser()).toThrow('user:profile:read');
171
+ });
172
+ });
173
+ describe('useFiasPreviewState', () => {
174
+ it('registers a keyed provider + restore handler with the bridge', () => {
175
+ const { useFiasPreviewState } = require('./hooks');
176
+ const getState = jest.fn(() => ({ rows: 5 }));
177
+ const onRestore = jest.fn();
178
+ useFiasPreviewState('report', getState, onRestore);
179
+ expect(mockBridge.registerPreviewState).toHaveBeenCalledWith('report', expect.any(Function), expect.any(Function));
180
+ // The wrappers proxy to the live getState/onRestore (via refs).
181
+ const [, provider, restore] = mockBridge.registerPreviewState.mock.calls[0];
182
+ expect(provider()).toEqual({ rows: 5 });
183
+ restore({ rows: 9 });
184
+ expect(onRestore).toHaveBeenCalledWith({ rows: 9 });
185
+ });
186
+ it('throws when used outside FiasProvider', () => {
187
+ mockContextValue = null;
188
+ const { useFiasPreviewState } = require('./hooks');
189
+ expect(() => useFiasPreviewState('k', () => null, () => { })).toThrow('useBridge must be used within a <FiasProvider>');
190
+ });
127
191
  });
128
192
  describe('useFiasTheme', () => {
129
193
  it('returns initial theme from bridge and subscribes to updates', () => {
@@ -143,32 +207,48 @@ describe('SDK hooks', () => {
143
207
  expect(capturedEffectCleanup).toBe(unsubscribe);
144
208
  });
145
209
  });
210
+ describe('useFiasNavigation', () => {
211
+ it('navigateTo and openArche call the bridge with the right ops', () => {
212
+ const { useFiasNavigation } = require('./hooks');
213
+ const nav = useFiasNavigation();
214
+ nav.navigateTo('/settings');
215
+ expect(mockBridge.request).toHaveBeenCalledWith('navigate', { path: '/settings' });
216
+ nav.openArche('arc_0123456789abcdef0123456789abcdef', { newTab: true });
217
+ expect(mockBridge.request).toHaveBeenCalledWith('open_arche', {
218
+ archeId: 'arc_0123456789abcdef0123456789abcdef',
219
+ newTab: true,
220
+ });
221
+ // newTab defaults to false when opts is omitted.
222
+ nav.openArche('arche_guilds');
223
+ expect(mockBridge.request).toHaveBeenCalledWith('open_arche', {
224
+ archeId: 'arche_guilds',
225
+ newTab: false,
226
+ });
227
+ });
228
+ });
146
229
  describe('useFiasStorage', () => {
147
230
  it('returns storage API methods that call bridge.request', async () => {
148
231
  const { useFiasStorage } = require('./hooks');
149
232
  const storage = useFiasStorage();
150
- mockBridge.request.mockResolvedValue('file content');
233
+ mockBridge.request.mockResolvedValue(output({ content: 'file content', exists: true }));
151
234
  await storage.readFile('/test.txt');
152
- expect(mockBridge.request).toHaveBeenCalledWith('storage_read', { path: '/test.txt' });
153
- mockBridge.request.mockResolvedValue(undefined);
235
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '/test.txt' }));
236
+ mockBridge.request.mockResolvedValue(output({ success: true }));
154
237
  await storage.writeFile('/test.txt', 'data');
155
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
156
- path: '/test.txt',
157
- content: 'data',
158
- });
159
- mockBridge.request.mockResolvedValue(['a.txt', 'b.txt']);
238
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', { path: '/test.txt', content: 'data' }));
239
+ mockBridge.request.mockResolvedValue(output({ files: ['a.txt', 'b.txt'] }));
160
240
  await storage.listFiles('/prefix');
161
- expect(mockBridge.request).toHaveBeenCalledWith('storage_list', { prefix: '/prefix' });
162
- mockBridge.request.mockResolvedValue(undefined);
241
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'list', { prefix: '/prefix' }));
242
+ mockBridge.request.mockResolvedValue(output({ success: true }));
163
243
  await storage.deleteFile('/test.txt');
164
- expect(mockBridge.request).toHaveBeenCalledWith('storage_delete', { path: '/test.txt' });
244
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'delete', { path: '/test.txt' }));
165
245
  });
166
246
  it('calls listFiles without prefix', async () => {
167
247
  const { useFiasStorage } = require('./hooks');
168
248
  const storage = useFiasStorage();
169
- mockBridge.request.mockResolvedValue([]);
249
+ mockBridge.request.mockResolvedValue(output({ files: [] }));
170
250
  await storage.listFiles();
171
- expect(mockBridge.request).toHaveBeenCalledWith('storage_list', { prefix: undefined });
251
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'list', { prefix: undefined }));
172
252
  });
173
253
  });
174
254
  describe('useEntityInvocation', () => {
@@ -211,6 +291,23 @@ describe('SDK hooks', () => {
211
291
  await expect(api.invoke({ entityId: 'ent_test', input: 'hello' })).rejects.toThrow('string error');
212
292
  });
213
293
  });
294
+ describe('NLLB translation entity', () => {
295
+ it('exposes the NLLB_LANGUAGES picker list keyed by FLORES code', () => {
296
+ const { NLLB_LANGUAGES } = require('./hooks');
297
+ expect(Array.isArray(NLLB_LANGUAGES)).toBe(true);
298
+ const spanish = NLLB_LANGUAGES.find((l) => l.code === 'spa_Latn');
299
+ expect(spanish?.label).toBe('Spanish');
300
+ });
301
+ it('NLLB_LANGUAGES codes are well-formed FLORES-200 and unique (guards typos/drift)', () => {
302
+ const { NLLB_LANGUAGES } = require('./hooks');
303
+ const codes = NLLB_LANGUAGES.map((l) => l.code);
304
+ for (const l of NLLB_LANGUAGES) {
305
+ expect(l.label.length).toBeGreaterThan(0);
306
+ expect(l.code).toMatch(/^[a-z]{3}_[A-Z][a-z]{3}$/);
307
+ }
308
+ expect(new Set(codes).size).toBe(codes.length); // no duplicate codes
309
+ });
310
+ });
214
311
  describe('useFiasNavigation', () => {
215
312
  it('returns current path and subscribes to updates', () => {
216
313
  mockBridge.getCurrentPath.mockReturnValue('/current');
@@ -261,16 +358,14 @@ describe('SDK hooks', () => {
261
358
  it('without persistKey, does not touch storage on mount or setCurrentStep', () => {
262
359
  const { useStepNavigation } = require('./hooks');
263
360
  const result = useStepNavigation('step-1');
264
- expect(mockBridge.request).not.toHaveBeenCalledWith('storage_read', expect.anything());
361
+ expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
265
362
  result.setCurrentStep('step-2');
266
- expect(mockBridge.request).not.toHaveBeenCalledWith('storage_write', expect.anything());
363
+ expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
267
364
  });
268
365
  it('with persistKey, reads persisted value on mount', () => {
269
366
  const { useStepNavigation } = require('./hooks');
270
367
  useStepNavigation('step-1', { persistKey: 'currentStep' });
271
- expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
272
- path: '__state/currentStep',
273
- });
368
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/currentStep' }));
274
369
  });
275
370
  it('with persistKey, writes on setCurrentStep (after debounce flush)', () => {
276
371
  jest.useFakeTimers();
@@ -281,12 +376,12 @@ describe('SDK hooks', () => {
281
376
  result.setCurrentStep('step-2');
282
377
  expect(mockSetState).toHaveBeenCalledWith('step-2');
283
378
  // Setter updates in-memory state immediately; the write is debounced.
284
- expect(mockBridge.request).not.toHaveBeenCalledWith('storage_write', expect.anything());
379
+ expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
285
380
  jest.advanceTimersByTime(260);
286
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
381
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
287
382
  path: '__state/currentStep',
288
383
  content: JSON.stringify('step-2'),
289
- });
384
+ }));
290
385
  }
291
386
  finally {
292
387
  jest.useRealTimers();
@@ -307,20 +402,17 @@ describe('SDK hooks', () => {
307
402
  hostCallback('step-from-host');
308
403
  expect(mockSetState).toHaveBeenCalledWith('step-from-host');
309
404
  jest.advanceTimersByTime(260);
310
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
405
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
311
406
  path: '__state/currentStep',
312
407
  content: JSON.stringify('step-from-host'),
313
- });
408
+ }));
314
409
  }
315
410
  finally {
316
411
  jest.useRealTimers();
317
412
  }
318
413
  });
319
414
  it('with persistKey, persisted value wins over initialStep on mount', async () => {
320
- mockBridge.request.mockResolvedValue({
321
- content: JSON.stringify('step-persisted'),
322
- exists: true,
323
- });
415
+ mockBridge.request.mockResolvedValue(output({ content: JSON.stringify('step-persisted'), exists: true }));
324
416
  const { useStepNavigation } = require('./hooks');
325
417
  useStepNavigation('step-initial', { persistKey: 'currentStep' });
326
418
  // Let the storage_read promise resolve
@@ -332,26 +424,39 @@ describe('SDK hooks', () => {
332
424
  });
333
425
  describe('usePersistentState', () => {
334
426
  function countStorageWrites(key) {
335
- return mockBridge.request.mock.calls.filter((call) => call[0] === 'storage_write' && call[1].path === `__state/${key}`).length;
427
+ return mockBridge.request.mock.calls.filter((call) => {
428
+ if (call[0] !== 'entity_invoke')
429
+ return false;
430
+ const payload = call[1].payload;
431
+ return payload?.op === 'write' && payload?.path === `__state/${key}`;
432
+ }).length;
336
433
  }
337
434
  it('reads initial value from storage on mount', () => {
338
435
  const { usePersistentState } = require('./hooks');
339
436
  usePersistentState('counter', 0);
340
- expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
341
- path: '__state/counter',
342
- });
437
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/counter' }));
343
438
  });
344
439
  it('applies persisted value after storage_read resolves', async () => {
345
- mockBridge.request.mockResolvedValue({
346
- content: JSON.stringify(42),
347
- exists: true,
348
- });
440
+ mockBridge.request.mockResolvedValue(output({ content: JSON.stringify(42), exists: true }));
349
441
  const { usePersistentState } = require('./hooks');
350
442
  usePersistentState('counter', 0);
351
443
  await Promise.resolve();
352
444
  await Promise.resolve();
353
445
  expect(mockSetState).toHaveBeenCalledWith(42);
354
446
  });
447
+ it('falls back to the initial value when the mount storage_read is denied/fails', async () => {
448
+ // e.g. the plugin didn't declare `storage:sandbox`. Must not become a
449
+ // silent unhandled rejection, and must not clobber the initial value.
450
+ mockBridge.request.mockRejectedValue(new Error('Permission denied: storage:sandbox'));
451
+ const { usePersistentState } = require('./hooks');
452
+ const [value] = usePersistentState('counter', 7);
453
+ // Let the rejection handler run.
454
+ await Promise.resolve();
455
+ await Promise.resolve();
456
+ // No persisted value was applied — the setter was never called with data.
457
+ expect(mockSetState).not.toHaveBeenCalled();
458
+ expect(value).toBe(7);
459
+ });
355
460
  it('setter updates in-memory value synchronously, defers write', () => {
356
461
  jest.useFakeTimers();
357
462
  try {
@@ -365,10 +470,10 @@ describe('SDK hooks', () => {
365
470
  expect(countStorageWrites('counter')).toBe(0);
366
471
  jest.advanceTimersByTime(260);
367
472
  expect(countStorageWrites('counter')).toBe(1);
368
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
473
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
369
474
  path: '__state/counter',
370
475
  content: JSON.stringify(1),
371
- });
476
+ }));
372
477
  }
373
478
  finally {
374
479
  jest.useRealTimers();
@@ -390,14 +495,15 @@ describe('SDK hooks', () => {
390
495
  expect(writesDuringBurst).toBeLessThanOrEqual(1);
391
496
  // Let the trailing-edge flush fire
392
497
  jest.advanceTimersByTime(260);
393
- const writes = mockBridge.request.mock.calls.filter((call) => call[0] === 'storage_write');
498
+ const writes = mockBridge.request.mock.calls.filter((call) => call[0] === 'entity_invoke' &&
499
+ call[1].payload?.op === 'write');
394
500
  // Total writes over the burst: at most 2 (one at maxWait, one trailing).
395
501
  // Never 60. Final write contains the last value.
396
502
  expect(writes.length).toBeGreaterThanOrEqual(1);
397
503
  expect(writes.length).toBeLessThanOrEqual(2);
398
504
  expect(writes[writes.length - 1][1]).toEqual({
399
- path: '__state/counter',
400
- content: JSON.stringify(60),
505
+ entityId: entity_ops_1.PLUGIN_STORAGE_ENTITY_ID,
506
+ payload: { op: 'write', path: '__state/counter', content: JSON.stringify(60) },
401
507
  });
402
508
  }
403
509
  finally {
@@ -438,10 +544,10 @@ describe('SDK hooks', () => {
438
544
  expect(capturedEffectCleanup).not.toBeNull();
439
545
  capturedEffectCleanup();
440
546
  expect(countStorageWrites('counter')).toBe(1);
441
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
547
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
442
548
  path: '__state/counter',
443
549
  content: JSON.stringify(7),
444
- });
550
+ }));
445
551
  }
446
552
  finally {
447
553
  jest.useRealTimers();
@@ -724,139 +830,49 @@ describe('SDK hooks', () => {
724
830
  });
725
831
  expect(fin.status).toBe('uploaded');
726
832
  });
727
- it('upload() orchestrates SHA-256 + presigned PUT + finalize, stripping Content-Length', async () => {
833
+ it('upload() delegates to the host vault_documents_upload op with base64 bytes', async () => {
728
834
  const { useVaultDocuments } = freshHooks();
729
835
  const api = useVaultDocuments();
730
- // Mock crypto.subtle.digest to return a known 32-byte digest.
731
- const fakeDigest = new Uint8Array(32);
732
- for (let i = 0; i < 32; i++)
733
- fakeDigest[i] = i;
734
- const originalCrypto = globalThis.crypto;
735
- globalThis.crypto = {
736
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
737
- };
738
- // Mock fetch.
739
- const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
740
- const originalFetch = globalThis.fetch;
741
- globalThis.fetch = fetchMock;
742
- try {
743
- mockBridge.request.mockResolvedValueOnce({
744
- documentId: 'vdoc_u',
745
- uploadUrl: 'https://s3/up',
746
- requiredHeaders: {
747
- 'Content-Type': 'application/octet-stream',
748
- 'Content-Length': '4',
749
- 'x-amz-checksum-sha256': 'aaa',
750
- 'x-amz-tagging': 'pending=true',
751
- },
752
- uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
753
- });
754
- mockBridge.request.mockResolvedValueOnce({
755
- documentId: 'vdoc_u',
756
- status: 'uploaded',
757
- });
758
- const bytes = new Uint8Array([1, 2, 3, 4]);
759
- const result = await api.upload(bytes, {
760
- name: 'tiny.bin',
761
- mimeType: 'application/octet-stream',
762
- });
763
- // SDK rendered the digest as lowercase hex.
764
- const expectedHex = Array.from(fakeDigest)
765
- .map((b) => b.toString(16).padStart(2, '0'))
766
- .join('');
767
- expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({
768
- sizeBytes: 4,
769
- checksumSha256: expectedHex,
770
- }));
771
- // Browser fetch was called with Content-Length stripped from the
772
- // headers (browser auto-computes it from body.byteLength).
773
- expect(fetchMock).toHaveBeenCalledTimes(1);
774
- const fetchOpts = fetchMock.mock.calls[0][1];
775
- const headerKeys = Object.keys(fetchOpts.headers).map((k) => k.toLowerCase());
776
- expect(headerKeys).not.toContain('content-length');
777
- expect(headerKeys).toContain('content-type');
778
- expect(headerKeys).toContain('x-amz-checksum-sha256');
779
- // Finalize used the same hex digest.
780
- expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'vault_documents_upload_finalize', {
781
- documentId: 'vdoc_u',
782
- checksumSha256: expectedHex,
783
- });
784
- expect(result.status).toBe('uploaded');
785
- }
786
- finally {
787
- globalThis.crypto = originalCrypto;
788
- globalThis.fetch = originalFetch;
789
- }
836
+ // Single host-orchestrated op: the host (parent window) runs init → PUT →
837
+ // finalize. The iframe never fetches (its CSP is connect-src 'none'), so
838
+ // the SDK no longer touches crypto/fetch here.
839
+ mockBridge.request.mockResolvedValueOnce({ documentId: 'vdoc_u', status: 'uploaded' });
840
+ const result = await api.upload(new Uint8Array([1, 2, 3, 4]), {
841
+ name: 'tiny.bin',
842
+ mimeType: 'application/octet-stream',
843
+ tags: ['t'],
844
+ });
845
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
846
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({
847
+ name: 'tiny.bin',
848
+ mimeType: 'application/octet-stream',
849
+ bytes: btoa(String.fromCharCode(1, 2, 3, 4)),
850
+ tags: ['t'],
851
+ }));
852
+ expect(result.status).toBe('uploaded');
790
853
  });
791
- it('upload() throws on non-2xx PUT response', async () => {
854
+ it('upload() rejects when the host upload op fails (e.g. PUT 403)', async () => {
792
855
  const { useVaultDocuments } = freshHooks();
793
856
  const api = useVaultDocuments();
794
- const fakeDigest = new Uint8Array(32);
795
- const originalCrypto = globalThis.crypto;
796
- globalThis.crypto = {
797
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
798
- };
799
- const fetchMock = jest.fn().mockResolvedValue({
800
- ok: false,
801
- status: 403,
802
- statusText: 'Forbidden',
803
- text: () => Promise.resolve('signature mismatch'),
804
- });
805
- const originalFetch = globalThis.fetch;
806
- globalThis.fetch = fetchMock;
807
- try {
808
- mockBridge.request.mockResolvedValueOnce({
809
- documentId: 'vdoc_u',
810
- uploadUrl: 'https://s3/up',
811
- requiredHeaders: { 'Content-Type': 'application/octet-stream' },
812
- uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
813
- });
814
- await expect(api.upload(new Uint8Array([1, 2, 3]), {
815
- name: 'x.bin',
816
- mimeType: 'application/octet-stream',
817
- })).rejects.toThrow(/403/);
818
- }
819
- finally {
820
- globalThis.crypto = originalCrypto;
821
- globalThis.fetch = originalFetch;
822
- }
857
+ // The host now performs the PUT; a failure surfaces as a rejected op.
858
+ mockBridge.request.mockRejectedValueOnce(new Error('Vault upload PUT failed (403): Forbidden'));
859
+ await expect(api.upload(new Uint8Array([1, 2, 3]), {
860
+ name: 'x.bin',
861
+ mimeType: 'application/octet-stream',
862
+ })).rejects.toThrow(/403/);
823
863
  });
824
864
  it('upload() accepts a raw ArrayBuffer as well as a Uint8Array', async () => {
825
865
  const { useVaultDocuments } = freshHooks();
826
866
  const api = useVaultDocuments();
827
- const fakeDigest = new Uint8Array(32);
828
- const originalCrypto = globalThis.crypto;
829
- globalThis.crypto = {
830
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
831
- };
832
- const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
833
- const originalFetch = globalThis.fetch;
834
- globalThis.fetch = fetchMock;
835
- try {
836
- mockBridge.request.mockResolvedValueOnce({
837
- documentId: 'vdoc_u',
838
- uploadUrl: 'https://s3/up',
839
- requiredHeaders: {},
840
- uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
841
- });
842
- mockBridge.request.mockResolvedValueOnce({
843
- documentId: 'vdoc_u',
844
- status: 'uploaded',
845
- });
846
- const buffer = new ArrayBuffer(8);
847
- const view = new Uint8Array(buffer);
848
- view.set([1, 2, 3, 4, 5, 6, 7, 8]);
849
- const result = await api.upload(buffer, {
850
- name: 'ab.bin',
851
- mimeType: 'application/octet-stream',
852
- });
853
- expect(result.status).toBe('uploaded');
854
- expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({ sizeBytes: 8 }));
855
- }
856
- finally {
857
- globalThis.crypto = originalCrypto;
858
- globalThis.fetch = originalFetch;
859
- }
867
+ mockBridge.request.mockResolvedValueOnce({ documentId: 'vdoc_u', status: 'uploaded' });
868
+ const buffer = new ArrayBuffer(8);
869
+ new Uint8Array(buffer).set([1, 2, 3, 4, 5, 6, 7, 8]);
870
+ const result = await api.upload(buffer, {
871
+ name: 'ab.bin',
872
+ mimeType: 'application/octet-stream',
873
+ });
874
+ expect(result.status).toBe('uploaded');
875
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({ bytes: btoa(String.fromCharCode(1, 2, 3, 4, 5, 6, 7, 8)) }));
860
876
  });
861
877
  });
862
878
  describe('useSurface', () => {
@@ -928,137 +944,118 @@ describe('SDK hooks', () => {
928
944
  await expect(api.generate({ prompt: 'x' })).rejects.toThrow('Lyria timeout');
929
945
  });
930
946
  });
931
- describe('useBackgroundRemoval', () => {
947
+ describe('useClientEntity', () => {
932
948
  it('returns initial state from useState', () => {
933
- const { useBackgroundRemoval } = require('./hooks');
934
- const api = useBackgroundRemoval();
949
+ const { useClientEntity } = require('./hooks');
950
+ const api = useClientEntity();
935
951
  expect(api.isLoading).toBe(false);
936
- expect(api.result).toBeNull();
937
952
  expect(api.error).toBeNull();
938
- expect(typeof api.removeBackground).toBe('function');
939
- });
940
- it('removeBackground posts client_entity_invoke targeting the imgly entity with 120s timeout', async () => {
941
- const { useBackgroundRemoval } = require('./hooks');
942
- const cutout = new Blob([new Uint8Array([9])], { type: 'image/png' });
943
- mockBridge.request.mockResolvedValue({ image: cutout });
944
- const api = useBackgroundRemoval();
945
- const input = new Blob([new Uint8Array([1])], { type: 'image/png' });
946
- const result = await api.removeBackground(input);
947
- expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_imgly_bgrm_000000000000', input: { image: input } }, 120000);
948
- expect(result).toBe(cutout);
949
- });
950
- it('rejects inputs over 25 MiB without calling the bridge', async () => {
951
- const { useBackgroundRemoval } = require('./hooks');
952
- // 25 MiB + 1 byte. Build a backing buffer (cheap — happy-dom Blob
953
- // reads `size` from the constructor input length without
954
- // materializing the array contents).
955
- const oversized = new Blob([new Uint8Array(25 * 1024 * 1024 + 1)], { type: 'image/png' });
956
- const api = useBackgroundRemoval();
957
- await expect(api.removeBackground(oversized)).rejects.toThrow('25 MiB');
958
- expect(mockBridge.request).not.toHaveBeenCalled();
953
+ expect(typeof api.invoke).toBe('function');
954
+ });
955
+ it('invoke posts client_entity_invoke with the given entityId/input and 120s timeout', async () => {
956
+ const { useClientEntity } = require('./hooks');
957
+ const response = { issues: [] };
958
+ mockBridge.request.mockResolvedValue(response);
959
+ const api = useClientEntity();
960
+ const input = { text: 'helo wrld' };
961
+ const result = await api.invoke('ent_intg_grammar_check_000000000', input);
962
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_grammar_check_000000000', input }, 120000);
963
+ expect(result).toBe(response);
959
964
  });
960
965
  it('rethrows bridge errors and wraps non-Error rejections', async () => {
961
- const { useBackgroundRemoval } = require('./hooks');
966
+ const { useClientEntity } = require('./hooks');
962
967
  mockBridge.request.mockRejectedValue('rate limit hit');
963
- const api = useBackgroundRemoval();
964
- await expect(api.removeBackground(new Blob([new Uint8Array([1])], { type: 'image/png' }))).rejects.toThrow('rate limit hit');
968
+ const api = useClientEntity();
969
+ await expect(api.invoke('ent_intg_grammar_check_000000000', {})).rejects.toThrow('rate limit hit');
965
970
  });
966
971
  });
967
972
  describe('useFiasDataStore', () => {
968
973
  it('createCollection forwards name + default userScope', async () => {
969
974
  const { useFiasDataStore } = require('./hooks');
970
975
  const ds = useFiasDataStore();
971
- mockBridge.request.mockResolvedValueOnce({
972
- collection: { name: 'scores', userScope: 'user' },
973
- });
976
+ mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'scores', userScope: 'user' } }));
974
977
  const coll = await ds.createCollection('scores');
975
- expect(mockBridge.request).toHaveBeenCalledWith('data_create_collection', {
978
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
976
979
  name: 'scores',
977
980
  userScope: 'user',
978
- });
981
+ }));
979
982
  expect(coll.name).toBe('scores');
980
983
  });
981
984
  it('createCollection honors an explicit userScope=shared', async () => {
982
985
  const { useFiasDataStore } = require('./hooks');
983
986
  const ds = useFiasDataStore();
984
- mockBridge.request.mockResolvedValueOnce({
985
- collection: { name: 'global', userScope: 'shared' },
986
- });
987
+ mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'global', userScope: 'shared' } }));
987
988
  await ds.createCollection('global', { userScope: 'shared' });
988
- expect(mockBridge.request).toHaveBeenCalledWith('data_create_collection', {
989
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
989
990
  name: 'global',
990
991
  userScope: 'shared',
991
- });
992
+ }));
992
993
  });
993
994
  it('listCollections returns the array unwrapped from the bridge envelope', async () => {
994
995
  const { useFiasDataStore } = require('./hooks');
995
996
  const ds = useFiasDataStore();
996
- mockBridge.request.mockResolvedValueOnce({
997
- collections: [{ name: 'a' }, { name: 'b' }],
998
- });
997
+ mockBridge.request.mockResolvedValueOnce(output({ collections: [{ name: 'a' }, { name: 'b' }] }));
999
998
  const list = await ds.listCollections();
1000
999
  expect(list).toHaveLength(2);
1001
- expect(mockBridge.request).toHaveBeenCalledWith('data_list_collections', {});
1000
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'list_collections'));
1002
1001
  });
1003
1002
  it('deleteCollection forwards the name', async () => {
1004
1003
  const { useFiasDataStore } = require('./hooks');
1005
1004
  const ds = useFiasDataStore();
1006
- mockBridge.request.mockResolvedValueOnce(undefined);
1005
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1007
1006
  await ds.deleteCollection('scores');
1008
- expect(mockBridge.request).toHaveBeenCalledWith('data_delete_collection', {
1009
- name: 'scores',
1010
- });
1007
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete_collection', { name: 'scores' }));
1011
1008
  });
1012
1009
  it('put forwards collection / key / data', async () => {
1013
1010
  const { useFiasDataStore } = require('./hooks');
1014
1011
  const ds = useFiasDataStore();
1015
- mockBridge.request.mockResolvedValueOnce(undefined);
1012
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1016
1013
  await ds.put('scores', 'level-1', { score: 100 });
1017
- expect(mockBridge.request).toHaveBeenCalledWith('data_put', {
1014
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
1018
1015
  collection: 'scores',
1019
1016
  key: 'level-1',
1020
1017
  data: { score: 100 },
1021
- });
1018
+ }));
1022
1019
  });
1023
1020
  it('get unwraps { data } from the bridge envelope', async () => {
1024
1021
  const { useFiasDataStore } = require('./hooks');
1025
1022
  const ds = useFiasDataStore();
1026
- mockBridge.request.mockResolvedValueOnce({ data: { score: 50 } });
1023
+ mockBridge.request.mockResolvedValueOnce(output({ data: { score: 50 } }));
1027
1024
  const result = await ds.get('scores', 'level-1');
1028
1025
  expect(result).toEqual({ score: 50 });
1029
1026
  });
1030
1027
  it('get returns null when the bridge says data is null', async () => {
1031
1028
  const { useFiasDataStore } = require('./hooks');
1032
1029
  const ds = useFiasDataStore();
1033
- mockBridge.request.mockResolvedValueOnce({ data: null });
1030
+ mockBridge.request.mockResolvedValueOnce(output({ data: null }));
1034
1031
  const result = await ds.get('scores', 'missing');
1035
1032
  expect(result).toBeNull();
1036
1033
  });
1037
1034
  it('query forwards collection + options to data_query', async () => {
1038
1035
  const { useFiasDataStore } = require('./hooks');
1039
1036
  const ds = useFiasDataStore();
1040
- mockBridge.request.mockResolvedValueOnce({ documents: [], nextCursor: null });
1037
+ mockBridge.request.mockResolvedValueOnce(output({ documents: [], nextCursor: null }));
1041
1038
  await ds.query('scores', {
1042
1039
  filters: [{ field: 'score', op: 'gte', value: 50 }],
1043
1040
  orderBy: { field: 'score', direction: 'desc' },
1044
1041
  limit: 10,
1045
1042
  });
1046
- expect(mockBridge.request).toHaveBeenCalledWith('data_query', {
1043
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'query', {
1047
1044
  collection: 'scores',
1048
1045
  filters: [{ field: 'score', op: 'gte', value: 50 }],
1049
1046
  orderBy: { field: 'score', direction: 'desc' },
1050
1047
  limit: 10,
1051
- });
1048
+ }));
1052
1049
  });
1053
1050
  it('delete forwards collection + key', async () => {
1054
1051
  const { useFiasDataStore } = require('./hooks');
1055
1052
  const ds = useFiasDataStore();
1056
- mockBridge.request.mockResolvedValueOnce(undefined);
1053
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1057
1054
  await ds.delete('scores', 'level-1');
1058
- expect(mockBridge.request).toHaveBeenCalledWith('data_delete', {
1055
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete', {
1059
1056
  collection: 'scores',
1060
1057
  key: 'level-1',
1061
- });
1058
+ }));
1062
1059
  });
1063
1060
  });
1064
1061
  describe('useFiasStore', () => {
@@ -1069,8 +1066,8 @@ describe('SDK hooks', () => {
1069
1066
  mockBridge.request.mockResolvedValueOnce(products).mockResolvedValueOnce(entitlements);
1070
1067
  const api = useFiasStore();
1071
1068
  // Effect kicks the two bridge calls.
1072
- expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'store_get_products', {});
1073
- expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'store_get_entitlements', {});
1069
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_products'));
1070
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_entitlements'));
1074
1071
  expect(typeof api.purchase).toBe('function');
1075
1072
  });
1076
1073
  it('hasEntitlement checks productIdentifier + isActive', () => {
@@ -1090,9 +1087,7 @@ describe('SDK hooks', () => {
1090
1087
  .mockResolvedValueOnce({ success: true, purchaseId: 'p_1' });
1091
1088
  const api = useFiasStore();
1092
1089
  const result = await api.purchase('gems_100');
1093
- expect(mockBridge.request).toHaveBeenLastCalledWith('store_purchase', {
1094
- productIdentifier: 'gems_100',
1095
- });
1090
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', { productIdentifier: 'gems_100' }));
1096
1091
  expect(result.purchaseId).toBe('p_1');
1097
1092
  });
1098
1093
  it('purchase forwards options.quantity to the bridge', async () => {
@@ -1103,10 +1098,10 @@ describe('SDK hooks', () => {
1103
1098
  .mockResolvedValueOnce({ success: true });
1104
1099
  const api = useFiasStore();
1105
1100
  await api.purchase('gems_100', { quantity: 3 });
1106
- expect(mockBridge.request).toHaveBeenLastCalledWith('store_purchase', {
1101
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', {
1107
1102
  productIdentifier: 'gems_100',
1108
1103
  quantity: 3,
1109
- });
1104
+ }));
1110
1105
  });
1111
1106
  it('getPurchaseHistory unwraps { purchases }', async () => {
1112
1107
  const { useFiasStore } = require('./hooks');
@@ -1117,7 +1112,7 @@ describe('SDK hooks', () => {
1117
1112
  const api = useFiasStore();
1118
1113
  const history = await api.getPurchaseHistory();
1119
1114
  expect(history).toHaveLength(1);
1120
- expect(mockBridge.request).toHaveBeenLastCalledWith('store_get_purchase_history', {});
1115
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_purchase_history'));
1121
1116
  });
1122
1117
  it('restorePurchases returns the restored entitlements array directly', async () => {
1123
1118
  const { useFiasStore } = require('./hooks');
@@ -1128,7 +1123,7 @@ describe('SDK hooks', () => {
1128
1123
  const api = useFiasStore();
1129
1124
  const restored = await api.restorePurchases();
1130
1125
  expect(restored).toHaveLength(1);
1131
- expect(mockBridge.request).toHaveBeenLastCalledWith('store_restore', {});
1126
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'restore'));
1132
1127
  });
1133
1128
  it('swallows a mount-time bridge rejection and clears isLoading', async () => {
1134
1129
  const { useFiasStore } = require('./hooks');
@@ -1166,24 +1161,16 @@ describe('SDK hooks', () => {
1166
1161
  });
1167
1162
  describe('usePersistentState', () => {
1168
1163
  it('reads from storage on mount and applies the parsed value', async () => {
1169
- mockBridge.request.mockResolvedValue({
1170
- content: JSON.stringify({ score: 50 }),
1171
- exists: true,
1172
- });
1164
+ mockBridge.request.mockResolvedValue(output({ content: JSON.stringify({ score: 50 }), exists: true }));
1173
1165
  const { usePersistentState } = require('./hooks');
1174
1166
  const [value, setValue] = usePersistentState('game-state', { score: 0 });
1175
1167
  expect(value).toEqual({ score: 0 }); // initial (useState returns initial)
1176
- expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
1177
- path: '__state/game-state',
1178
- });
1168
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/game-state' }));
1179
1169
  // setValue is exposed regardless of load completion.
1180
1170
  expect(typeof setValue).toBe('function');
1181
1171
  });
1182
1172
  it('tolerates a corrupted JSON read and keeps initial value', async () => {
1183
- mockBridge.request.mockResolvedValue({
1184
- content: 'not-valid-json{',
1185
- exists: true,
1186
- });
1173
+ mockBridge.request.mockResolvedValue(output({ content: 'not-valid-json{', exists: true }));
1187
1174
  const { usePersistentState } = require('./hooks');
1188
1175
  const [value] = usePersistentState('x', { count: 1 });
1189
1176
  // initial state from useState (the mocked react.useState returns
@@ -1192,7 +1179,7 @@ describe('SDK hooks', () => {
1192
1179
  await new Promise((r) => setTimeout(r, 0));
1193
1180
  });
1194
1181
  it('setValue with a function variant resolves prev from valueRef', () => {
1195
- mockBridge.request.mockResolvedValue({ content: null, exists: false });
1182
+ mockBridge.request.mockResolvedValue(output({ content: null, exists: false }));
1196
1183
  const { usePersistentState } = require('./hooks');
1197
1184
  const [, setValue] = usePersistentState('counter', 0);
1198
1185
  // Calling setValue with a function should not throw and should
@@ -1221,9 +1208,7 @@ describe('SDK hooks', () => {
1221
1208
  });
1222
1209
  const { useStepNavigation } = require('./hooks');
1223
1210
  useStepNavigation(undefined, { persistKey: 'wizard' });
1224
- expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
1225
- path: '__state/wizard',
1226
- });
1211
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/wizard' }));
1227
1212
  });
1228
1213
  it('setCurrentStep updates state and schedules a persistent write when persistKey is set', () => {
1229
1214
  const { useStepNavigation } = require('./hooks');
@@ -1286,4 +1271,86 @@ describe('SDK hooks', () => {
1286
1271
  });
1287
1272
  });
1288
1273
  });
1274
+ describe('useDataSubscription (ADR-012 Phase M4)', () => {
1275
+ const flush = async () => {
1276
+ // Drain the effect's async subscribe chain (request → listeners → state).
1277
+ await Promise.resolve();
1278
+ await Promise.resolve();
1279
+ await Promise.resolve();
1280
+ };
1281
+ function subscribed(subscriptionId = 'lsub_1') {
1282
+ mockBridge.request.mockResolvedValue({
1283
+ output: { kind: 'subscription', subscriptionId },
1284
+ });
1285
+ }
1286
+ let mockBridge;
1287
+ beforeEach(() => {
1288
+ mockBridge = createMockBridge();
1289
+ mockContextValue = mockBridge;
1290
+ mockSingletonBridge = mockBridge;
1291
+ });
1292
+ it('subscribes via entity_invoke on the datastore entity and refetches once live', async () => {
1293
+ subscribed();
1294
+ const onInvalidate = jest.fn().mockResolvedValue(undefined);
1295
+ const { useDataSubscription } = require('./hooks');
1296
+ useDataSubscription('scores', onInvalidate);
1297
+ await flush();
1298
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
1299
+ entityId: 'ent_intg_plugin_datastore_000000',
1300
+ payload: { op: 'subscribe', collection: 'scores', workspaceId: undefined },
1301
+ });
1302
+ expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_1', expect.any(Function));
1303
+ expect(mockBridge.onSubscriptionEnded).toHaveBeenCalledWith('lsub_1', expect.any(Function));
1304
+ // Subscribe-then-fetch: the initial refetch fires AFTER the
1305
+ // subscription is live, closing the write-races-the-handshake gap.
1306
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
1307
+ expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
1308
+ });
1309
+ it('runs onInvalidate single-flight with exactly one trailing call', async () => {
1310
+ subscribed();
1311
+ let resolveFirst = () => undefined;
1312
+ const onInvalidate = jest
1313
+ .fn()
1314
+ .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
1315
+ .mockResolvedValue(undefined);
1316
+ const { useDataSubscription } = require('./hooks');
1317
+ useDataSubscription('scores', onInvalidate);
1318
+ await flush();
1319
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // initial, still in flight
1320
+ const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
1321
+ eventCb();
1322
+ eventCb();
1323
+ eventCb();
1324
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // burst coalesced behind the in-flight run
1325
+ resolveFirst();
1326
+ await flush();
1327
+ expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing call
1328
+ });
1329
+ it('surfaces terminal endings and releases the subscription on cleanup', async () => {
1330
+ subscribed();
1331
+ const { useDataSubscription } = require('./hooks');
1332
+ useDataSubscription('scores', jest.fn().mockResolvedValue(undefined));
1333
+ await flush();
1334
+ const endedCb = mockBridge.onSubscriptionEnded.mock.calls[0][1];
1335
+ endedCb('collection_deleted');
1336
+ expect(mockSetState).toHaveBeenCalledWith({
1337
+ status: 'ended',
1338
+ endedReason: 'collection_deleted',
1339
+ });
1340
+ capturedEffectCleanup?.();
1341
+ expect(mockBridge.releaseSubscription).toHaveBeenCalledWith('lsub_1');
1342
+ });
1343
+ it('a malformed subscribe response ends with transport_error', async () => {
1344
+ mockBridge.request.mockResolvedValue({ output: { data: null } });
1345
+ const onInvalidate = jest.fn();
1346
+ const { useDataSubscription } = require('./hooks');
1347
+ useDataSubscription('scores', onInvalidate);
1348
+ await flush();
1349
+ expect(mockSetState).toHaveBeenCalledWith({
1350
+ status: 'ended',
1351
+ endedReason: 'transport_error',
1352
+ });
1353
+ expect(onInvalidate).not.toHaveBeenCalled();
1354
+ });
1355
+ });
1289
1356
  //# sourceMappingURL=hooks.test.js.map