@flowdular/sdk 0.3.0 → 0.3.2

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 (174) hide show
  1. package/.ai/platform-capabilities.md +4 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  4. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  5. package/.ai/references/catalog/module.json +11 -1
  6. package/.ai/references/catalog/package.json +2 -2
  7. package/.ai/references/catalog/spec/module.yaml +25 -4
  8. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  9. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  10. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  11. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  12. package/.ai/references/catalog/src/client/api.ts +124 -36
  13. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  14. package/.ai/references/catalog/src/client/state.ts +169 -3
  15. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  16. package/.ai/references/catalog/src/domain/types.ts +20 -0
  17. package/.ai/references/catalog/src/platform.ts +20 -0
  18. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  19. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  20. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  21. package/.ai/references/catalog/src/services/migration.ts +27 -1
  22. package/.ai/references/catalog/src/services/repository.ts +31 -2
  23. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  24. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  25. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  26. package/.ai/references/catalog/tests/export.test.ts +134 -0
  27. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  28. package/.ai/references/catalog/tests/list.test.ts +217 -0
  29. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  30. package/.ai/references/catalog/tests/module.test.ts +2 -1
  31. package/.ai/references/catalog/tests/support/database.ts +14 -0
  32. package/.ai/references/catalog/translations/en.json +35 -4
  33. package/.ai/references/catalog/translations/pl.json +35 -4
  34. package/.ai/references/catalog.provenance.json +34 -26
  35. package/.ai/skills/cli-extension/SKILL.md +1 -1
  36. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  37. package/README.md +1 -1
  38. package/assets/flowdular-banner.webp +0 -0
  39. package/modules/access/module.json +1 -1
  40. package/modules/access/spec/module.yaml +1 -1
  41. package/modules/agents/module.json +1 -1
  42. package/modules/agents/spec/module.yaml +1 -1
  43. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  44. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  45. package/modules/approvals/module.json +2 -2
  46. package/modules/approvals/package.json +1 -1
  47. package/modules/approvals/spec/module.yaml +12 -3
  48. package/modules/approvals/src/domain/capability.ts +12 -0
  49. package/modules/approvals/src/domain/grant.ts +69 -0
  50. package/modules/approvals/src/domain/types.ts +14 -0
  51. package/modules/approvals/src/index.ts +9 -0
  52. package/modules/approvals/src/platform.ts +8 -0
  53. package/modules/approvals/src/server/runtime.ts +4 -0
  54. package/modules/approvals/src/services/approvals-service.ts +80 -0
  55. package/modules/approvals/src/services/database-repository.ts +65 -6
  56. package/modules/approvals/src/services/migration.ts +34 -0
  57. package/modules/approvals/src/services/repository.ts +8 -0
  58. package/modules/audit/module.json +1 -1
  59. package/modules/audit/spec/module.yaml +1 -1
  60. package/modules/auth/module.json +2 -2
  61. package/modules/auth/package.json +1 -1
  62. package/modules/auth/spec/module.yaml +2 -2
  63. package/modules/auth/src/middleware/authentication.ts +5 -1
  64. package/modules/auth/src/services/auth-service.ts +26 -0
  65. package/modules/auth/src/services/database-repository.ts +9 -0
  66. package/modules/auth/src/services/repository.ts +2 -0
  67. package/modules/automations/module.json +1 -1
  68. package/modules/automations/spec/module.yaml +1 -1
  69. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  70. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  71. package/modules/connectors/module.json +8 -4
  72. package/modules/connectors/package.json +2 -1
  73. package/modules/connectors/spec/module.yaml +3 -2
  74. package/modules/connectors/src/cli/commands.json +17 -0
  75. package/modules/connectors/src/cli/index.ts +126 -0
  76. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  77. package/modules/connectors/src/services/credential-vault.ts +6 -0
  78. package/modules/connectors/src/services/migration.ts +36 -0
  79. package/modules/directory/module.json +1 -1
  80. package/modules/directory/spec/module.yaml +1 -1
  81. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  82. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  83. package/modules/documents/module.json +8 -4
  84. package/modules/documents/package.json +2 -1
  85. package/modules/documents/spec/module.yaml +3 -2
  86. package/modules/documents/src/cli/commands.json +17 -0
  87. package/modules/documents/src/cli/index.ts +145 -0
  88. package/modules/documents/src/services/database-repository.ts +15 -4
  89. package/modules/documents/src/services/documents-service.ts +13 -9
  90. package/modules/documents/src/services/migration.ts +35 -0
  91. package/modules/documents/src/services/repository.ts +12 -2
  92. package/modules/documents/src/services/storage-rotation.ts +157 -0
  93. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  94. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  95. package/modules/exports/module.json +8 -4
  96. package/modules/exports/package.json +2 -1
  97. package/modules/exports/spec/module.yaml +3 -2
  98. package/modules/exports/src/cli/commands.json +17 -0
  99. package/modules/exports/src/cli/index.ts +145 -0
  100. package/modules/exports/src/server/index.ts +0 -1
  101. package/modules/exports/src/services/data-classes.ts +16 -13
  102. package/modules/exports/src/services/database-repository.ts +30 -32
  103. package/modules/exports/src/services/migration.ts +27 -0
  104. package/modules/exports/src/services/repository.ts +9 -10
  105. package/modules/exports/src/services/storage-rotation.ts +138 -0
  106. package/modules/import/module.json +1 -1
  107. package/modules/import/spec/module.yaml +1 -1
  108. package/modules/metering/module.json +1 -1
  109. package/modules/metering/spec/module.yaml +1 -1
  110. package/modules/notifications/module.json +1 -1
  111. package/modules/notifications/spec/module.yaml +1 -1
  112. package/modules/reports/module.json +1 -1
  113. package/modules/reports/spec/module.yaml +1 -1
  114. package/modules/sandbox/module.json +1 -1
  115. package/modules/sandbox/spec/module.yaml +1 -1
  116. package/modules/search/module.json +1 -1
  117. package/modules/search/spec/module.yaml +1 -1
  118. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  119. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  120. package/modules/system/module.json +3 -2
  121. package/modules/system/package.json +2 -2
  122. package/modules/system/spec/module.yaml +98 -1
  123. package/modules/system/src/client/ModulesView.tsrx +101 -8
  124. package/modules/system/src/client/api.ts +52 -11
  125. package/modules/system/src/client/module-columns.tsrx +32 -6
  126. package/modules/system/src/client/state.ts +26 -0
  127. package/modules/system/src/domain/modules.ts +93 -0
  128. package/modules/system/src/index.ts +13 -0
  129. package/modules/system/src/platform.ts +41 -1
  130. package/modules/system/src/server/capability.ts +6 -0
  131. package/modules/system/src/server/endpoints.ts +125 -5
  132. package/modules/system/src/server/index.ts +26 -0
  133. package/modules/system/src/server/module-catalog.ts +19 -0
  134. package/modules/system/src/server/runtime.ts +102 -0
  135. package/modules/system/src/services/database-repository.ts +91 -0
  136. package/modules/system/src/services/migration.ts +39 -0
  137. package/modules/system/src/services/module-activation-service.ts +245 -0
  138. package/modules/system/src/services/repository.ts +17 -0
  139. package/modules/system/translations/en.json +17 -1
  140. package/modules/system/translations/pl.json +17 -1
  141. package/modules/users/module.json +2 -2
  142. package/modules/users/package.json +1 -1
  143. package/modules/users/spec/module.yaml +6 -9
  144. package/modules/users/src/client/UsersView.tsrx +27 -13
  145. package/modules/users/src/client/member-columns.tsrx +13 -4
  146. package/modules/users/src/client/state.ts +16 -0
  147. package/modules/users/src/services/users-service.ts +3 -0
  148. package/modules/users/translations/en.json +2 -1
  149. package/modules/users/translations/pl.json +2 -1
  150. package/modules/workflows/module.json +2 -2
  151. package/modules/workflows/package.json +1 -1
  152. package/modules/workflows/spec/module.yaml +2 -2
  153. package/modules/workflows/src/services/database-repository.ts +93 -53
  154. package/package.json +1 -1
  155. package/packages/client/package.json +1 -0
  156. package/packages/client/src/ApplicationShell.tsrx +46 -5
  157. package/packages/client/src/index.ts +5 -0
  158. package/packages/client/src/shell/modules.ts +49 -0
  159. package/packages/client/src/shell/types.ts +3 -0
  160. package/packages/contracts/src/index.ts +11 -1
  161. package/packages/database/src/backup.ts +1 -0
  162. package/packages/database/src/migrations.ts +7 -0
  163. package/packages/harness/src/runtime.ts +169 -10
  164. package/packages/harness/src/tool-adapters.ts +6 -13
  165. package/packages/kernel/src/approval-grant.ts +310 -0
  166. package/packages/kernel/src/index.ts +20 -0
  167. package/packages/server/src/endpoint.ts +17 -1
  168. package/packages/server/src/index.ts +10 -0
  169. package/packages/server/src/module-activation.ts +69 -0
  170. package/packages/storage/src/envelope.ts +70 -21
  171. package/packages/storage/src/index.ts +7 -1
  172. package/packages/storage/src/port.ts +12 -1
  173. package/packages/storage/src/reseal.ts +128 -0
  174. package/assets/flowdular-banner.png +0 -0
@@ -317,4 +317,273 @@ describe('catalog lifecycle and history endpoints', () => {
317
317
  (await (await runtime.service()).get('tenant-a', item.id))?.status,
318
318
  ).toBe('active');
319
319
  });
320
+
321
+ it('rejects a bulk lifecycle mutation without a valid CSRF token', async () => {
322
+ const actor = principal([CATALOG_PERMISSIONS.manage]);
323
+ const runtime = catalogRuntime(await catalogTestProvider());
324
+ const item = await (
325
+ await runtime.service()
326
+ ).create('tenant-a', seedInput('A-1'), OWNER);
327
+ const response = await route(
328
+ createCatalogRoutes(authRuntime(actor), runtime),
329
+ '/api/catalog/items/archive-many',
330
+ 'POST',
331
+ ).handler(
332
+ context(
333
+ mutation('/api/catalog/items/archive-many', { ids: [item.id] }, 'bad'),
334
+ actor,
335
+ ),
336
+ );
337
+ expect(response.status).toBe(403);
338
+ expect(
339
+ (await (await runtime.service()).get('tenant-a', item.id))?.status,
340
+ ).toBe('active');
341
+ });
342
+
343
+ it('bounds the ids of a bulk lifecycle mutation', async () => {
344
+ const actor = principal([CATALOG_PERMISSIONS.manage]);
345
+ const routes = createCatalogRoutes(
346
+ authRuntime(actor),
347
+ catalogRuntime(await catalogTestProvider()),
348
+ );
349
+ for (const body of [
350
+ {},
351
+ { ids: [] },
352
+ { ids: Array.from({ length: 101 }, (_, index) => `id-${index}`) },
353
+ { ids: ['same', 'same'] },
354
+ { ids: [''] },
355
+ { ids: ['x'.repeat(129)] },
356
+ ]) {
357
+ for (const path of [
358
+ '/api/catalog/items/archive-many',
359
+ '/api/catalog/items/restore-many',
360
+ ]) {
361
+ const response = await route(routes, path, 'POST').handler(
362
+ context(mutation(path, body), actor),
363
+ );
364
+ expect(response.status, `${path} ${JSON.stringify(body)}`).toBe(400);
365
+ expect(await response.json()).toMatchObject({
366
+ error: { code: 'INVALID_INPUT' },
367
+ });
368
+ }
369
+ }
370
+ });
371
+
372
+ it('answers one outcome per id and one history row per accepted transition', async () => {
373
+ const actor = principal([
374
+ CATALOG_PERMISSIONS.read,
375
+ CATALOG_PERMISSIONS.manage,
376
+ ]);
377
+ const runtime = catalogRuntime(await catalogTestProvider());
378
+ const service = await runtime.service();
379
+ const first = await service.create('tenant-a', seedInput('A-1'), OWNER);
380
+ const second = await service.create('tenant-a', seedInput('A-2'), OWNER);
381
+ const foreign = await service.create('tenant-b', seedInput('B-1'), OWNER);
382
+ const routes = createCatalogRoutes(authRuntime(actor), runtime);
383
+
384
+ const archived = await route(
385
+ routes,
386
+ '/api/catalog/items/archive-many',
387
+ 'POST',
388
+ ).handler(
389
+ context(
390
+ mutation('/api/catalog/items/archive-many', {
391
+ ids: [first.id, 'missing', foreign.id, second.id],
392
+ }),
393
+ actor,
394
+ ),
395
+ );
396
+ expect(archived.status).toBe(200);
397
+ expect(await archived.json()).toEqual({
398
+ outcomes: [
399
+ { id: first.id, outcome: 'updated' },
400
+ { id: 'missing', outcome: 'not-found' },
401
+ { id: foreign.id, outcome: 'not-found' },
402
+ { id: second.id, outcome: 'updated' },
403
+ ],
404
+ });
405
+ expect((await service.get('tenant-b', foreign.id))?.status).toBe('active');
406
+
407
+ const restored = await route(
408
+ routes,
409
+ '/api/catalog/items/restore-many',
410
+ 'POST',
411
+ ).handler(
412
+ context(
413
+ mutation('/api/catalog/items/restore-many', { ids: [first.id] }),
414
+ actor,
415
+ ),
416
+ );
417
+ expect(await restored.json()).toEqual({
418
+ outcomes: [{ id: first.id, outcome: 'updated' }],
419
+ });
420
+
421
+ for (const [id, actions] of [
422
+ [first.id, ['restored', 'archived', 'created']],
423
+ [second.id, ['archived', 'created']],
424
+ ] as const) {
425
+ const history = await service.history('tenant-a', {
426
+ recordId: id,
427
+ limit: 10,
428
+ cursor: null,
429
+ });
430
+ expect(history.entries.map((entry) => entry.action)).toEqual(actions);
431
+ expect(history.entries[0]?.actor).toEqual({
432
+ kind: 'user',
433
+ id: 'account-a',
434
+ label: 'Owner',
435
+ });
436
+ }
437
+ });
438
+ });
439
+
440
+ function seedInput(sku: string) {
441
+ return {
442
+ sku,
443
+ name: 'Item ' + sku,
444
+ kind: 'product' as const,
445
+ unit: 'each',
446
+ basePriceMinor: 100,
447
+ currency: 'EUR',
448
+ };
449
+ }
450
+
451
+ const OWNER = { kind: 'user', id: 'account-a', label: 'Owner' } as const;
452
+
453
+ function listRequest(query: Record<string, string>): Request {
454
+ return new Request(
455
+ 'https://erp.example/api/catalog/items?' +
456
+ new URLSearchParams(query).toString(),
457
+ );
458
+ }
459
+
460
+ /* Flips the first character of one dot-separated segment of a signed cursor. */
461
+ function tamper(cursor: string, segment: number): string {
462
+ const parts = cursor.split('.');
463
+ const target = parts[segment]!;
464
+ const first = target[0] === 'A' ? 'B' : 'A';
465
+ parts[segment] = first + target.slice(1);
466
+ return parts.join('.');
467
+ }
468
+
469
+ interface ListBody {
470
+ readonly items: readonly { readonly id: string; readonly sku: string }[];
471
+ readonly page: { readonly nextCursor: string | null; readonly limit: number };
472
+ }
473
+
474
+ describe('catalog items list endpoint', () => {
475
+ async function listing(count: number) {
476
+ const actor = principal([CATALOG_PERMISSIONS.read]);
477
+ const runtime = catalogRuntime(await catalogTestProvider());
478
+ const service = await runtime.service();
479
+ for (let index = 0; index < count; index += 1) {
480
+ await service.create(
481
+ 'tenant-a',
482
+ seedInput(`SKU-${String(index).padStart(3, '0')}`),
483
+ OWNER,
484
+ );
485
+ }
486
+ await service.create('tenant-b', seedInput('OTHER-1'), OWNER);
487
+ const list = route(
488
+ createCatalogRoutes(authRuntime(actor), runtime),
489
+ '/api/catalog/items',
490
+ 'GET',
491
+ );
492
+ const read = async (
493
+ query: Record<string, string>,
494
+ as = actor,
495
+ ): Promise<{ status: number; body: ListBody }> => {
496
+ const response = await list.handler(context(listRequest(query), as));
497
+ return {
498
+ status: response.status,
499
+ body: (await response.json()) as ListBody,
500
+ };
501
+ };
502
+ return { read, runtime };
503
+ }
504
+
505
+ it('answers consecutive pages without overlap or gap, a cursor only on a full page', async () => {
506
+ const { read } = await listing(5);
507
+ const first = await read({ limit: '2', sort: 'sku' });
508
+ expect(first.status).toBe(200);
509
+ expect(first.body.items.map((item) => item.sku)).toEqual([
510
+ 'SKU-000',
511
+ 'SKU-001',
512
+ ]);
513
+ expect(first.body.page).toEqual({
514
+ nextCursor: expect.any(String),
515
+ limit: 2,
516
+ });
517
+ const second = await read({
518
+ limit: '2',
519
+ sort: 'sku',
520
+ cursor: first.body.page.nextCursor!,
521
+ });
522
+ expect(second.body.items.map((item) => item.sku)).toEqual([
523
+ 'SKU-002',
524
+ 'SKU-003',
525
+ ]);
526
+ const third = await read({
527
+ limit: '2',
528
+ sort: 'sku',
529
+ cursor: second.body.page.nextCursor!,
530
+ });
531
+ expect(third.body.items.map((item) => item.sku)).toEqual(['SKU-004']);
532
+ expect(third.body.page.nextCursor).toBeNull();
533
+
534
+ const whole = await read({});
535
+ expect(whole.body.items).toHaveLength(5);
536
+ expect(whole.body.page).toEqual({ nextCursor: null, limit: 50 });
537
+ });
538
+
539
+ it('refuses a tampered, foreign, refiltered or re-sorted cursor and an unknown sort', async () => {
540
+ const { read } = await listing(3);
541
+ const query = { limit: '2', sort: 'name', direction: 'asc', q: 'sku' };
542
+ const cursor = (await read(query)).body.page.nextCursor!;
543
+ expect((await read({ ...query, cursor })).status).toBe(200);
544
+
545
+ const refused = async (
546
+ changes: Record<string, string>,
547
+ as?: AuthPrincipal,
548
+ ) => {
549
+ const result = await read({ ...query, ...changes }, as);
550
+ expect(result.status, JSON.stringify(changes)).toBe(400);
551
+ return (result.body as unknown as { error: { code: string } }).error.code;
552
+ };
553
+ for (const segment of [0, 1, 2]) {
554
+ expect(await refused({ cursor: tamper(cursor, segment) })).toBe(
555
+ 'CURSOR_INVALID',
556
+ );
557
+ }
558
+ expect(
559
+ await refused(
560
+ { cursor },
561
+ principal([CATALOG_PERMISSIONS.read], 'tenant-b'),
562
+ ),
563
+ ).toBe('CURSOR_INVALID');
564
+ expect(await refused({ cursor, q: 'other' })).toBe('CURSOR_INVALID');
565
+ expect(await refused({ cursor, kind: 'product' })).toBe('CURSOR_INVALID');
566
+ expect(await refused({ cursor, sort: 'sku' })).toBe('CURSOR_INVALID');
567
+ expect(await refused({ cursor, direction: 'desc' })).toBe('CURSOR_INVALID');
568
+ expect(await refused({ cursor: 'not-a-cursor' })).toBe('CURSOR_INVALID');
569
+ expect(await refused({ sort: 'price' })).toBe('INVALID_INPUT');
570
+ expect(await refused({ direction: 'up' })).toBe('INVALID_INPUT');
571
+ expect(await refused({ status: 'gone' })).toBe('INVALID_INPUT');
572
+ expect(await refused({ limit: '201' })).toBe('INVALID_INPUT');
573
+ });
574
+
575
+ it('pushes the filters into the read and never crosses the tenant', async () => {
576
+ const { read, runtime } = await listing(2);
577
+ const service = await runtime.service();
578
+ const [archived] = (await read({ sort: 'sku' })).body.items;
579
+ await service.archive('tenant-a', archived!.id, OWNER);
580
+ expect(
581
+ (await read({ status: 'archived' })).body.items.map((item) => item.sku),
582
+ ).toEqual(['SKU-000']);
583
+ expect(
584
+ (await read({ status: 'active' })).body.items.map((item) => item.sku),
585
+ ).toEqual(['SKU-001']);
586
+ expect((await read({ kind: 'service' })).body.items).toEqual([]);
587
+ expect((await read({ q: 'other' })).body.items).toEqual([]);
588
+ });
320
589
  });
@@ -0,0 +1,134 @@
1
+ import type { DefinedListExport } from '@flowdular/sdk/server';
2
+ import { EXPORT_LISTS_CAPABILITY } from '@flowdular/sdk/modules/exports';
3
+ import type { PlatformServerContext } from '@flowdular/sdk/modules/auth/server';
4
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
5
+ import { CATALOG_ITEMS_EXPORT_LIST_ID } from '../src/domain/lists.ts';
6
+ import { createServerComposition } from '../src/platform.ts';
7
+ import {
8
+ createCatalogRuntime,
9
+ type CatalogRuntime,
10
+ } from '../src/server/runtime.ts';
11
+ import {
12
+ catalogTestProvider,
13
+ closeCatalogTestDatabases,
14
+ } from './support/database.ts';
15
+
16
+ const ACTOR = { kind: 'user', id: 'account-a', label: 'Owner' } as const;
17
+
18
+ const disposals = new Set<() => Promise<void>>();
19
+
20
+ afterEach(async () => {
21
+ await Promise.all([...disposals].map((dispose) => dispose()));
22
+ disposals.clear();
23
+ });
24
+
25
+ afterAll(closeCatalogTestDatabases);
26
+
27
+ /* The composition context a test needs: leased databases, a capability map
28
+ and inert registries for what this test does not observe. */
29
+ async function compose(registered: Map<string, readonly DefinedListExport[]>) {
30
+ const capabilities = new Map<string, unknown>();
31
+ capabilities.set(EXPORT_LISTS_CAPABILITY, {
32
+ register: (moduleId: string, lists: readonly DefinedListExport[]) =>
33
+ registered.set(moduleId, lists),
34
+ });
35
+ const context = {
36
+ environment: { NODE_ENV: 'test' },
37
+ databases: await catalogTestProvider(),
38
+ auth: {},
39
+ agentTools: { register: () => undefined },
40
+ dataClasses: { declare: () => undefined },
41
+ capabilities: {
42
+ register: (id: string, value: unknown) => capabilities.set(id, value),
43
+ get: (id: string) => capabilities.get(id) ?? null,
44
+ has: (id: string) => capabilities.has(id),
45
+ },
46
+ } as unknown as PlatformServerContext;
47
+ const composition = createServerComposition(context);
48
+ disposals.add(() => composition.dispose?.() ?? Promise.resolve());
49
+ return composition;
50
+ }
51
+
52
+ async function seed(runtime: CatalogRuntime, tenantId: string, skus: string[]) {
53
+ const service = await runtime.service();
54
+ for (const sku of skus) {
55
+ await service.create(
56
+ tenantId,
57
+ {
58
+ sku,
59
+ name: 'Item ' + sku,
60
+ kind: 'product',
61
+ unit: 'pcs',
62
+ basePriceMinor: 1_000,
63
+ currency: 'PLN',
64
+ },
65
+ ACTOR,
66
+ );
67
+ }
68
+ }
69
+
70
+ describe('catalog items list export', () => {
71
+ it('registers catalog.core.items with its columns while composing', async () => {
72
+ const registered = new Map<string, readonly DefinedListExport[]>();
73
+ const composition = await compose(registered);
74
+ composition.start?.();
75
+ const lists = registered.get('catalog.core');
76
+ expect(lists?.map((list) => list.id)).toEqual([
77
+ CATALOG_ITEMS_EXPORT_LIST_ID,
78
+ ]);
79
+ expect(lists?.[0]).toMatchObject({
80
+ permission: 'catalog.items.read',
81
+ columns: [
82
+ { key: 'sku' },
83
+ { key: 'name' },
84
+ { key: 'kind' },
85
+ { key: 'unit' },
86
+ { key: 'basePriceMinor' },
87
+ { key: 'currency' },
88
+ { key: 'status' },
89
+ ],
90
+ });
91
+ expect(lists?.[0]?.header.trim()).toBe(
92
+ 'SKU,Name,Kind,Unit,Base price (minor units),Currency,Status',
93
+ );
94
+ });
95
+
96
+ it('streams one workspace in SKU order, page by page, and none of another', async () => {
97
+ const registered = new Map<string, readonly DefinedListExport[]>();
98
+ const composition = await compose(registered);
99
+ const list = registered.get('catalog.core')![0]!;
100
+ /* The composition owns its runtime; the rows are seeded through a second
101
+ one over the same cluster. */
102
+ const runtime = createCatalogRuntime({
103
+ databases: await catalogTestProvider(),
104
+ purpose: 'test',
105
+ });
106
+ disposals.add(() => runtime.dispose());
107
+ await seed(runtime, 'tenant-a', ['C-3', 'A-1', 'B-2', 'D-4', 'E-5']);
108
+ await seed(runtime, 'tenant-b', ['Z-9']);
109
+ const principal = {
110
+ accountId: 'account-a',
111
+ tenantId: 'tenant-a',
112
+ scopes: ['catalog.items.read'],
113
+ };
114
+
115
+ const first = await list.page(principal, null, 2);
116
+ expect(first.rows).toBe(2);
117
+ expect(first.records.map((record) => record.split(',')[0])).toEqual([
118
+ 'A-1',
119
+ 'B-2',
120
+ ]);
121
+ expect(first.nextCursor).not.toBeNull();
122
+ const second = await list.page(principal, first.nextCursor, 2);
123
+ expect(second.records.map((record) => record.split(',')[0])).toEqual([
124
+ 'C-3',
125
+ 'D-4',
126
+ ]);
127
+ const third = await list.page(principal, second.nextCursor, 2);
128
+ expect(third.records.map((record) => record.trim())).toEqual([
129
+ 'E-5,Item E-5,product,pcs,1000,PLN,active',
130
+ ]);
131
+ expect(third.nextCursor).toBeNull();
132
+ expect(composition.routes.length).toBeGreaterThan(0);
133
+ });
134
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type CatalogRuntime,
12
12
  } from '../src/server/runtime.ts';
13
13
  import {
14
+ allItems,
14
15
  catalogTestProvider,
15
16
  closeCatalogTestDatabases,
16
17
  } from './support/database.ts';
@@ -117,7 +118,7 @@ describe('catalog target idempotency', () => {
117
118
  )) as CatalogItem;
118
119
  expect(replay).toEqual(first);
119
120
  await expect(
120
- (await recoveredRuntime.service()).list('tenant-a'),
121
+ allItems(await recoveredRuntime.service(), 'tenant-a'),
121
122
  ).resolves.toEqual([first]);
122
123
  expect(
123
124
  (
@@ -155,7 +156,7 @@ describe('catalog target idempotency', () => {
155
156
  )) as CatalogItem;
156
157
  expect(tenantB.tenantId).toBe('tenant-b');
157
158
  await expect(
158
- (await recoveredRuntime.service()).list('tenant-b'),
159
+ allItems(await recoveredRuntime.service(), 'tenant-b'),
159
160
  ).resolves.toHaveLength(1);
160
161
  });
161
162
 
@@ -170,16 +171,16 @@ describe('catalog target idempotency', () => {
170
171
  const actor = { kind: 'user', id: 'owner-1', label: 'Owner' } as const;
171
172
  await (await runtime.service()).archive('tenant-a', created.id, actor);
172
173
  await (await runtime.service()).delete('tenant-a', created.id, actor);
173
- await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
174
- [],
175
- );
174
+ await expect(
175
+ allItems(await runtime.service(), 'tenant-a'),
176
+ ).resolves.toEqual([]);
176
177
 
177
178
  expect(
178
179
  await create.execute(input, context('catalog-delete-key-1')),
179
180
  ).toEqual(created);
180
- await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
181
- [],
182
- );
181
+ await expect(
182
+ allItems(await runtime.service(), 'tenant-a'),
183
+ ).resolves.toEqual([]);
183
184
  expect(
184
185
  (
185
186
  await (
@@ -234,9 +235,9 @@ describe('catalog target idempotency', () => {
234
235
  context('catalog-rollback-key-1'),
235
236
  ),
236
237
  ).rejects.toThrow(/forced_ledger_failure/);
237
- await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
238
- [],
239
- );
238
+ await expect(
239
+ allItems(await runtime.service(), 'tenant-a'),
240
+ ).resolves.toEqual([]);
240
241
  expect(await rowCounts(databases)).toEqual({
241
242
  items: 0,
242
243
  history: 0,
@@ -284,9 +285,9 @@ describe('catalog target idempotency', () => {
284
285
  context('catalog-corrupt-key-1'),
285
286
  ),
286
287
  ).rejects.toMatchObject({ code: 'CATALOG_IDEMPOTENCY_LEDGER_CORRUPT' });
287
- await expect((await recovered.service()).list('tenant-a')).resolves.toEqual(
288
- [],
289
- );
288
+ await expect(
289
+ allItems(await recovered.service(), 'tenant-a'),
290
+ ).resolves.toEqual([]);
290
291
  expect(
291
292
  await catalogAgentTools(recovered)[1]!.execute(
292
293
  input,
@@ -0,0 +1,217 @@
1
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
2
+ import type { CatalogItem, CatalogListSort } from '../src/domain/types.ts';
3
+ import {
4
+ CatalogService,
5
+ FIRST_LIST_PAGE,
6
+ type CatalogListInput,
7
+ } from '../src/services/catalog-service.ts';
8
+ import { listStatement } from '../src/services/database-repository.ts';
9
+ import {
10
+ closeCatalogTestDatabases,
11
+ createCatalogTestDatabase,
12
+ type CatalogTestDatabase,
13
+ } from './support/database.ts';
14
+
15
+ const ACTOR = { kind: 'user', id: 'account-1', label: 'Test user' } as const;
16
+
17
+ const databases = new Set<CatalogTestDatabase>();
18
+
19
+ afterEach(async () => {
20
+ await Promise.all([...databases].map((database) => database.dispose()));
21
+ databases.clear();
22
+ });
23
+
24
+ afterAll(closeCatalogTestDatabases);
25
+
26
+ async function fixture(): Promise<CatalogTestDatabase> {
27
+ const database = await createCatalogTestDatabase();
28
+ databases.add(database);
29
+ return database;
30
+ }
31
+
32
+ /* Names collide in case and prefix, SKUs sort differently from names, and the
33
+ update times are spread by the mutations below, so each order is its own. */
34
+ const SEED = [
35
+ ['B-2', 'bolt', 'product'],
36
+ ['A-1', 'Bolt', 'product'],
37
+ ['C-3', 'Anchor', 'service'],
38
+ ['D-4', 'anchor plate', 'product'],
39
+ ['E-5', 'Washer', 'service'],
40
+ ['F-6', 'washer_wide', 'product'],
41
+ ['G-7', '50% nut', 'product'],
42
+ ] as const;
43
+
44
+ async function seed(
45
+ service: CatalogService,
46
+ tenantId = 'tenant-a',
47
+ ): Promise<readonly CatalogItem[]> {
48
+ const items: CatalogItem[] = [];
49
+ for (const [sku, name, kind] of SEED) {
50
+ items.push(
51
+ await service.create(
52
+ tenantId,
53
+ { sku, name, kind, unit: 'pcs', basePriceMinor: 100, currency: 'EUR' },
54
+ ACTOR,
55
+ ),
56
+ );
57
+ }
58
+ return items;
59
+ }
60
+
61
+ /* The unbounded order, read once by the same ORDER BY the page uses. */
62
+ async function unbounded(
63
+ database: CatalogTestDatabase,
64
+ sort: CatalogListSort,
65
+ direction: 'asc' | 'desc',
66
+ tenantId = 'tenant-a',
67
+ ): Promise<readonly string[]> {
68
+ const expression = {
69
+ name: 'lower(name)',
70
+ sku: 'sku_normalized',
71
+ updatedAt: 'updated_at',
72
+ }[sort];
73
+ const order = direction === 'asc' ? 'ASC' : 'DESC';
74
+ const result = await database.runtime.transaction(
75
+ (transaction) =>
76
+ transaction.query<{ id: string }>({
77
+ text: `SELECT id FROM catalog_items WHERE tenant_id = $1
78
+ ORDER BY ${expression} ${order}, id ${order}`,
79
+ parameters: [tenantId],
80
+ }),
81
+ { access: 'read', tenantId },
82
+ );
83
+ return result.rows.map((row) => row.id);
84
+ }
85
+
86
+ async function walk(
87
+ service: CatalogService,
88
+ input: Omit<CatalogListInput, 'after'>,
89
+ ): Promise<readonly string[]> {
90
+ const ids: string[] = [];
91
+ let after: CatalogListInput['after'] = null;
92
+ for (;;) {
93
+ const page = await service.listPage('tenant-a', { ...input, after });
94
+ ids.push(...page.items.map((item) => item.id));
95
+ if (page.next === null) return ids;
96
+ after = page.next;
97
+ }
98
+ }
99
+
100
+ describe('catalog list pages', () => {
101
+ it('walks every order in pages of two exactly as the unbounded order reads', async () => {
102
+ const database = await fixture();
103
+ const service = new CatalogService(database.repository);
104
+ const items = await seed(service);
105
+ await seed(service, 'tenant-b');
106
+ /* Spread the update times so the recency order differs from creation. */
107
+ await service.archive('tenant-a', items[2]!.id, ACTOR);
108
+ await service.update(
109
+ 'tenant-a',
110
+ { ...items[5]!, name: 'washer_wide', basePriceMinor: 120 },
111
+ ACTOR,
112
+ );
113
+ for (const sort of ['name', 'sku', 'updatedAt'] as const) {
114
+ for (const direction of ['asc', 'desc'] as const) {
115
+ const expected = await unbounded(database, sort, direction);
116
+ expect(expected, `${sort} ${direction}`).toHaveLength(SEED.length);
117
+ expect(
118
+ await walk(service, {
119
+ ...FIRST_LIST_PAGE,
120
+ sort,
121
+ direction,
122
+ limit: 2,
123
+ }),
124
+ `${sort} ${direction}`,
125
+ ).toEqual(expected);
126
+ }
127
+ }
128
+ });
129
+
130
+ it('hands a cursor back only on a full page', async () => {
131
+ const service = new CatalogService((await fixture()).repository);
132
+ await seed(service);
133
+ const full = await service.listPage('tenant-a', {
134
+ ...FIRST_LIST_PAGE,
135
+ limit: SEED.length,
136
+ });
137
+ expect(full.items).toHaveLength(SEED.length);
138
+ expect(full.next).not.toBeNull();
139
+ const rest = await service.listPage('tenant-a', {
140
+ ...FIRST_LIST_PAGE,
141
+ limit: SEED.length,
142
+ after: full.next,
143
+ });
144
+ expect(rest).toEqual({ items: [], next: null });
145
+ const short = await service.listPage('tenant-a', {
146
+ ...FIRST_LIST_PAGE,
147
+ limit: SEED.length + 1,
148
+ });
149
+ expect(short.items).toHaveLength(SEED.length);
150
+ expect(short.next).toBeNull();
151
+ });
152
+
153
+ it('narrows by kind, status and a literal search in SQL', async () => {
154
+ const service = new CatalogService((await fixture()).repository);
155
+ const items = await seed(service);
156
+ await service.archive('tenant-a', items[0]!.id, ACTOR);
157
+ const skus = async (input: Partial<CatalogListInput>) =>
158
+ (
159
+ await service.listPage('tenant-a', { ...FIRST_LIST_PAGE, ...input })
160
+ ).items.map((item) => item.sku);
161
+
162
+ expect(await skus({ kind: 'service', sort: 'sku' })).toEqual([
163
+ 'C-3',
164
+ 'E-5',
165
+ ]);
166
+ expect(await skus({ status: 'archived' })).toEqual(['B-2']);
167
+ expect(
168
+ await skus({ status: 'active', kind: 'product', sort: 'sku' }),
169
+ ).toEqual(['A-1', 'D-4', 'F-6', 'G-7']);
170
+ expect(await skus({ search: 'BOLT', sort: 'sku' })).toEqual(['A-1', 'B-2']);
171
+ expect(await skus({ search: 'a-1' })).toEqual(['A-1']);
172
+ expect(await skus({ search: '%' })).toEqual(['G-7']);
173
+ expect(await skus({ search: '_wide' })).toEqual(['F-6']);
174
+ expect(await skus({ search: 'nothing-here' })).toEqual([]);
175
+ });
176
+
177
+ it('refuses an unknown sort, direction, limit or filter before reading', async () => {
178
+ const service = new CatalogService((await fixture()).repository);
179
+ await expect(
180
+ service.listPage('tenant-a', {
181
+ ...FIRST_LIST_PAGE,
182
+ sort: 'price' as CatalogListSort,
183
+ }),
184
+ ).rejects.toMatchObject({ code: 'INVALID_INPUT' });
185
+ await expect(
186
+ service.listPage('tenant-a', { ...FIRST_LIST_PAGE, limit: 201 }),
187
+ ).rejects.toMatchObject({ code: 'INVALID_INPUT' });
188
+ await expect(
189
+ service.listPage('tenant-a', {
190
+ ...FIRST_LIST_PAGE,
191
+ kind: 'bundle' as CatalogItem['kind'],
192
+ }),
193
+ ).rejects.toMatchObject({ code: 'INVALID_ITEM_KIND' });
194
+ });
195
+
196
+ it('orders by the indexed expression and binds every value', () => {
197
+ const statement = listStatement('tenant-a', {
198
+ sort: 'name',
199
+ direction: 'desc',
200
+ kind: 'product',
201
+ status: null,
202
+ term: 'bo%lt',
203
+ limit: 50,
204
+ after: { sortValue: 'bolt', id: 'item-1' },
205
+ });
206
+ expect(statement.text).toContain('ORDER BY lower(name) DESC, id DESC');
207
+ expect(statement.text).toContain('(lower(name), id) <');
208
+ expect(statement.parameters).toEqual([
209
+ 'tenant-a',
210
+ 'product',
211
+ '%bo\\%lt%',
212
+ 'bolt',
213
+ 'item-1',
214
+ 50,
215
+ ]);
216
+ });
217
+ });