@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
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type {
3
3
  DatabaseHandle,
4
+ DatabaseParameter,
5
+ DatabaseStatement,
4
6
  DatabaseTransaction,
5
7
  } from '@flowdular/sdk/database';
6
8
  import {
@@ -18,10 +20,12 @@ import {
18
20
  type TrackedFields,
19
21
  type UserActor,
20
22
  } from '@flowdular/sdk/kernel';
21
- import type { CatalogItem } from '../domain/types.ts';
23
+ import type { CatalogItem, CatalogListSort } from '../domain/types.ts';
22
24
  import { databaseMigrations } from './migration.ts';
23
25
  import {
24
26
  DuplicateSkuError,
27
+ type CatalogListPage,
28
+ type CatalogListRead,
25
29
  type CatalogRepository,
26
30
  type ExportCursor,
27
31
  } from './repository.ts';
@@ -36,12 +40,24 @@ import {
36
40
  const HISTORY_TABLE = 'catalog_items_history_v2';
37
41
 
38
42
  const COLUMNS = `id, tenant_id, sku, name, kind, unit, base_price_minor,
39
- currency, status, created_at`;
43
+ currency, status, created_at, updated_at`;
40
44
 
41
45
  /* Queries stay explicit. Catalog data never passes through a SQL rewriter, and
42
46
  values always use the adapter's parameter channel. */
43
- const LIST = `SELECT ${COLUMNS} FROM catalog_items
44
- WHERE tenant_id = $1 ORDER BY sku_normalized, id`;
47
+
48
+ /* The expression each list order sorts by, and the type the cursor value is
49
+ cast back to. Every expression is the second column of an index after
50
+ tenant_id, so a page is one index range scan. */
51
+ const LIST_SORTS: Readonly<
52
+ Record<
53
+ CatalogListSort,
54
+ { readonly expression: string; readonly cast: string }
55
+ >
56
+ > = {
57
+ name: { expression: 'lower(name)', cast: 'text' },
58
+ sku: { expression: 'sku_normalized', cast: 'text' },
59
+ updatedAt: { expression: 'updated_at', cast: 'bigint' },
60
+ };
45
61
 
46
62
  const LIST_FOR_EXPORT = `SELECT ${COLUMNS} FROM catalog_items
47
63
  WHERE tenant_id = $1
@@ -59,15 +75,16 @@ const FIND = `SELECT ${COLUMNS} FROM catalog_items WHERE tenant_id = $1 AND id =
59
75
 
60
76
  const INSERT = `INSERT INTO catalog_items
61
77
  (id, tenant_id, sku, sku_normalized, name, kind, unit,
62
- base_price_minor, currency, status, created_at)
63
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`;
78
+ base_price_minor, currency, status, created_at, updated_at)
79
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`;
64
80
 
65
81
  const UPDATE = `UPDATE catalog_items
66
- SET name = $1, kind = $2, unit = $3, base_price_minor = $4, currency = $5
67
- WHERE tenant_id = $6 AND id = $7 RETURNING ${COLUMNS}`;
82
+ SET name = $1, kind = $2, unit = $3, base_price_minor = $4, currency = $5,
83
+ updated_at = $6
84
+ WHERE tenant_id = $7 AND id = $8 RETURNING ${COLUMNS}`;
68
85
 
69
- const SET_STATUS = `UPDATE catalog_items SET status = $1 WHERE tenant_id = $2 AND id = $3
70
- RETURNING ${COLUMNS}`;
86
+ const SET_STATUS = `UPDATE catalog_items SET status = $1, updated_at = $2
87
+ WHERE tenant_id = $3 AND id = $4 RETURNING ${COLUMNS}`;
71
88
 
72
89
  const DELETE = 'DELETE FROM catalog_items WHERE tenant_id = $1 AND id = $2';
73
90
 
@@ -91,6 +108,11 @@ interface CatalogItemRow {
91
108
  currency: string;
92
109
  status: CatalogItem['status'];
93
110
  created_at: number | bigint | string;
111
+ updated_at: number | bigint | string;
112
+ }
113
+
114
+ interface CatalogListRow extends CatalogItemRow {
115
+ sort_value: string | number | bigint;
94
116
  }
95
117
 
96
118
  interface HistoryRow {
@@ -149,6 +171,61 @@ function fromRow(row: CatalogItemRow): CatalogItem {
149
171
  currency: row.currency,
150
172
  status: row.status,
151
173
  createdAt: integer(row.created_at, 'timestamp'),
174
+ updatedAt: integer(row.updated_at, 'timestamp'),
175
+ };
176
+ }
177
+
178
+ /* The term is matched as a substring, so the three characters LIKE reads as
179
+ syntax are escaped; the statement declares a backslash as ESCAPE. */
180
+ function likePattern(term: string): string {
181
+ return '%' + term.replace(/[\\%_]/g, (character) => '\\' + character) + '%';
182
+ }
183
+
184
+ /**
185
+ * One list page: the tenant predicate and the filters narrow, the keyset
186
+ * predicate over (sort expression, id) opens the page after the cursor, and
187
+ * ORDER BY names the same expression, so the read is one index range in one
188
+ * direction. The sort value is projected so the next cursor carries exactly
189
+ * what the database compared, never a JavaScript re-computation of it.
190
+ */
191
+ export function listStatement(
192
+ tenantId: string,
193
+ read: CatalogListRead,
194
+ ): DatabaseStatement {
195
+ const parameters: DatabaseParameter[] = [tenantId];
196
+ const filters = ['tenant_id = $1'];
197
+ if (read.kind !== null) {
198
+ parameters.push(read.kind);
199
+ filters.push(`kind = $${parameters.length}`);
200
+ }
201
+ if (read.status !== null) {
202
+ parameters.push(read.status);
203
+ filters.push(`status = $${parameters.length}`);
204
+ }
205
+ if (read.term !== null) {
206
+ parameters.push(likePattern(read.term));
207
+ filters.push(
208
+ `(lower(name) LIKE $${parameters.length} ESCAPE '\\'
209
+ OR sku_normalized LIKE $${parameters.length} ESCAPE '\\')`,
210
+ );
211
+ }
212
+ const sort = LIST_SORTS[read.sort];
213
+ if (read.after !== null) {
214
+ parameters.push(read.after.sortValue, read.after.id);
215
+ filters.push(
216
+ `(${sort.expression}, id) ${read.direction === 'asc' ? '>' : '<'}
217
+ ($${parameters.length - 1}::${sort.cast}, $${parameters.length}::text)`,
218
+ );
219
+ }
220
+ parameters.push(read.limit);
221
+ const order = read.direction === 'asc' ? 'ASC' : 'DESC';
222
+ return {
223
+ text: `SELECT ${COLUMNS}, ${sort.expression} AS sort_value
224
+ FROM catalog_items
225
+ WHERE ${filters.join(' AND ')}
226
+ ORDER BY ${sort.expression} ${order}, id ${order}
227
+ LIMIT $${parameters.length}`,
228
+ parameters,
152
229
  };
153
230
  }
154
231
 
@@ -209,17 +286,24 @@ export class DatabaseCatalogRepository implements CatalogRepository {
209
286
  private readonly readyPromise: Promise<void> = Promise.resolve(),
210
287
  ) {}
211
288
 
212
- async list(tenantId: string): Promise<readonly CatalogItem[]> {
289
+ async listPage(
290
+ tenantId: string,
291
+ read: CatalogListRead,
292
+ ): Promise<CatalogListPage> {
213
293
  await this.readyPromise;
214
294
  const result = await this.database.transaction(
215
295
  (transaction) =>
216
- transaction.query<CatalogItemRow>({
217
- text: LIST,
218
- parameters: [tenantId],
219
- }),
296
+ transaction.query<CatalogListRow>(listStatement(tenantId, read)),
220
297
  { access: 'read', tenantId },
221
298
  );
222
- return result.rows.map(fromRow);
299
+ const last = result.rows.at(-1);
300
+ return {
301
+ items: result.rows.map(fromRow),
302
+ next:
303
+ last && result.rows.length === read.limit
304
+ ? { sortValue: String(last.sort_value), id: last.id }
305
+ : null,
306
+ };
223
307
  }
224
308
 
225
309
  async listItemsForExport(
@@ -355,6 +439,7 @@ export class DatabaseCatalogRepository implements CatalogRepository {
355
439
  item.unit,
356
440
  item.basePriceMinor,
357
441
  item.currency,
442
+ Date.now(),
358
443
  item.tenantId,
359
444
  item.id,
360
445
  ],
@@ -390,9 +475,10 @@ export class DatabaseCatalogRepository implements CatalogRepository {
390
475
  async (transaction) => {
391
476
  const before = await this.#find(transaction, tenantId, id);
392
477
  if (!before) return null;
478
+ if (before.status === status) return before;
393
479
  const result = await transaction.query<CatalogItemRow>({
394
480
  text: SET_STATUS,
395
- parameters: [status, tenantId, id],
481
+ parameters: [status, Date.now(), tenantId, id],
396
482
  });
397
483
  const row = result.rows[0];
398
484
  if (!row) return null;
@@ -486,6 +572,7 @@ export class DatabaseCatalogRepository implements CatalogRepository {
486
572
  item.currency,
487
573
  item.status,
488
574
  item.createdAt,
575
+ item.updatedAt,
489
576
  ],
490
577
  });
491
578
  await appendRecordHistory(transaction, HISTORY_TABLE, {
@@ -0,0 +1,81 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ defineListExport,
4
+ type DefinedListExport,
5
+ } from '@flowdular/sdk/server';
6
+ import { CATALOG_PERMISSIONS } from '../acl/permissions.ts';
7
+ import { CATALOG_ITEMS_EXPORT_LIST_ID } from '../domain/lists.ts';
8
+ import type { CatalogItem } from '../domain/types.ts';
9
+ import type { CatalogRuntime } from '../server/runtime.ts';
10
+ import {
11
+ decodeListCursor,
12
+ encodeListCursor,
13
+ type CatalogListQuery,
14
+ } from '../api/list-cursor.ts';
15
+
16
+ /* The export walks the whole workspace in SKU order, the keyset the unique
17
+ (tenant_id, sku_normalized) index already carries, without a filter. */
18
+ const EXPORT_QUERY: CatalogListQuery = {
19
+ sort: 'sku',
20
+ direction: 'asc',
21
+ kind: null,
22
+ status: null,
23
+ search: '',
24
+ };
25
+
26
+ /**
27
+ * The workspace's catalog items as a CSV export. Every page is the same read
28
+ * the items list endpoint answers, under the tenant of the principal the job
29
+ * was started by and behind the permission the Catalog screen itself needs.
30
+ */
31
+ export function createCatalogItemsListExport(
32
+ runtime: CatalogRuntime,
33
+ ): DefinedListExport {
34
+ const cursorSecret = randomBytes(32);
35
+ return defineListExport<CatalogItem>({
36
+ id: CATALOG_ITEMS_EXPORT_LIST_ID,
37
+ label: 'Catalog items',
38
+ permission: CATALOG_PERMISSIONS.read,
39
+ columns: [
40
+ { key: 'sku', header: 'SKU', value: (row) => row.sku },
41
+ { key: 'name', header: 'Name', value: (row) => row.name },
42
+ { key: 'kind', header: 'Kind', value: (row) => row.kind },
43
+ { key: 'unit', header: 'Unit', value: (row) => row.unit },
44
+ {
45
+ key: 'basePriceMinor',
46
+ header: 'Base price (minor units)',
47
+ value: (row) => row.basePriceMinor,
48
+ },
49
+ { key: 'currency', header: 'Currency', value: (row) => row.currency },
50
+ { key: 'status', header: 'Status', value: (row) => row.status },
51
+ ],
52
+ page: async (principal, cursor, limit) => {
53
+ const page = await (
54
+ await runtime.service()
55
+ ).listPage(principal.tenantId, {
56
+ ...EXPORT_QUERY,
57
+ limit,
58
+ after:
59
+ cursor === null
60
+ ? null
61
+ : decodeListCursor(
62
+ cursorSecret,
63
+ cursor,
64
+ principal.tenantId,
65
+ EXPORT_QUERY,
66
+ ),
67
+ });
68
+ return {
69
+ rows: page.items,
70
+ nextCursor: page.next
71
+ ? encodeListCursor(
72
+ cursorSecret,
73
+ principal.tenantId,
74
+ EXPORT_QUERY,
75
+ page.next,
76
+ )
77
+ : null,
78
+ };
79
+ },
80
+ });
81
+ }
@@ -1,5 +1,8 @@
1
1
  import type { DatabaseMigration } from '@flowdular/sdk/database';
2
- import { postgresTenantTableState } from '@flowdular/sdk/database';
2
+ import {
3
+ migrationObjectState,
4
+ postgresTenantTableState,
5
+ } from '@flowdular/sdk/database';
3
6
 
4
7
  /* Every constant mirrors its migrations/<id>.up.sql file byte for byte;
5
8
  tests/migrations.test.ts fails on drift. */
@@ -108,6 +111,19 @@ CREATE POLICY catalog_idempotency_ledger_tenant_policy ON catalog_idempotency_le
108
111
  WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
109
112
  `;
110
113
 
114
+ export const CATALOG_MIGRATION_005_LIST_INDEXES = `ALTER TABLE catalog_items ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0;
115
+ -- The backfill runs as the migrator, which forced row security keeps out of
116
+ -- every row without a tenant setting; lift the flag for the statement only.
117
+ ALTER TABLE catalog_items NO FORCE ROW LEVEL SECURITY;
118
+ UPDATE catalog_items SET updated_at = created_at WHERE updated_at = 0;
119
+ ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
120
+ ALTER TABLE catalog_items ALTER COLUMN updated_at DROP DEFAULT;
121
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_name_idx
122
+ ON catalog_items (tenant_id, lower(name), id);
123
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_updated_idx
124
+ ON catalog_items (tenant_id, updated_at, id);
125
+ `;
126
+
111
127
  export const databaseMigrations: readonly DatabaseMigration[] = [
112
128
  {
113
129
  id: '0001_catalog_core',
@@ -168,4 +184,14 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
168
184
  ],
169
185
  ),
170
186
  },
187
+ {
188
+ id: '0005_catalog_list_indexes',
189
+ sql: { postgresql: CATALOG_MIGRATION_005_LIST_INDEXES },
190
+ inspectExisting: (database) =>
191
+ migrationObjectState([
192
+ () => database.schema.hasColumn('catalog_items', 'updated_at'),
193
+ () => database.schema.hasIndex('catalog_items_tenant_name_idx'),
194
+ () => database.schema.hasIndex('catalog_items_tenant_updated_idx'),
195
+ ]),
196
+ },
171
197
  ];
@@ -4,7 +4,12 @@ import type {
4
4
  HistoryPage,
5
5
  HistoryQuery,
6
6
  } from '@flowdular/sdk/kernel';
7
- import type { CatalogItem } from '../domain/types.ts';
7
+ import type {
8
+ CatalogItem,
9
+ CatalogItemKind,
10
+ CatalogItemStatus,
11
+ CatalogListSort,
12
+ } from '../domain/types.ts';
8
13
  import type { TargetIdempotencyRequest } from './target-idempotency.ts';
9
14
 
10
15
  export class DuplicateSkuError extends Error {
@@ -20,9 +25,33 @@ export interface ExportCursor {
20
25
  readonly id: string;
21
26
  }
22
27
 
28
+ /** Keyset position of a list page: the projected sort value and id of its last row. */
29
+ export interface CatalogPageKeyset {
30
+ readonly sortValue: string;
31
+ readonly id: string;
32
+ }
33
+
34
+ /** One list page read, already validated by the service. */
35
+ export interface CatalogListRead {
36
+ readonly sort: CatalogListSort;
37
+ readonly direction: 'asc' | 'desc';
38
+ readonly kind: CatalogItemKind | null;
39
+ readonly status: CatalogItemStatus | null;
40
+ /** Lower-cased substring of the name or the SKU; null narrows nothing. */
41
+ readonly term: string | null;
42
+ readonly limit: number;
43
+ readonly after: CatalogPageKeyset | null;
44
+ }
45
+
46
+ export interface CatalogListPage {
47
+ readonly items: readonly CatalogItem[];
48
+ /** The keyset after the last row, only when the page was full. */
49
+ readonly next: CatalogPageKeyset | null;
50
+ }
51
+
23
52
  /** The database-agnostic business port. No driver type crosses it. */
24
53
  export interface CatalogRepository {
25
- list(tenantId: string): Promise<readonly CatalogItem[]>;
54
+ listPage(tenantId: string, read: CatalogListRead): Promise<CatalogListPage>;
26
55
  listItemsForExport(
27
56
  tenantId: string,
28
57
  after: ExportCursor | null,
@@ -15,6 +15,7 @@ import {
15
15
  import { CATALOG_PERMISSIONS } from '../src/acl/permissions.ts';
16
16
  import { registerCatalogVariableSource } from '../src/domain/variables.ts';
17
17
  import {
18
+ allItems,
18
19
  catalogTestProvider,
19
20
  closeCatalogTestDatabases,
20
21
  } from './support/database.ts';
@@ -161,10 +162,10 @@ describe('catalog agent tools', () => {
161
162
  )) as CatalogItem;
162
163
  expect(created.tenantId).toBe('tenant-a');
163
164
  await expect(
164
- (await runtime.service()).list('tenant-a'),
165
+ allItems(await runtime.service(), 'tenant-a'),
165
166
  ).resolves.toHaveLength(1);
166
167
  await expect(
167
- (await runtime.service()).list('tenant-b'),
168
+ allItems(await runtime.service(), 'tenant-b'),
168
169
  ).resolves.toHaveLength(0);
169
170
  });
170
171
 
@@ -200,9 +201,9 @@ describe('catalog agent tools', () => {
200
201
  })
201
202
  ).entries[0]?.actor,
202
203
  ).toEqual(actor);
203
- await expect((await runtime.service()).list('tenant-b')).resolves.toEqual(
204
- [],
205
- );
204
+ await expect(
205
+ allItems(await runtime.service(), 'tenant-b'),
206
+ ).resolves.toEqual([]);
206
207
  });
207
208
 
208
209
  it('reuses the service validation so a tool cannot persist a bad currency', async () => {
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CatalogItem } from '../src/domain/types.ts';
3
+ import {
4
+ bulkOutcomeCounts,
5
+ bulkTargets,
6
+ catalogSort,
7
+ loadedListing,
8
+ pageCursor,
9
+ rememberNextCursor,
10
+ resetPageCursors,
11
+ searchPending,
12
+ type Listing,
13
+ } from '../src/client/state.ts';
14
+
15
+ function item(id: string, status: CatalogItem['status']): CatalogItem {
16
+ return {
17
+ id,
18
+ tenantId: 'tenant-a',
19
+ sku: id.toUpperCase(),
20
+ name: 'Item ' + id,
21
+ kind: 'product',
22
+ unit: 'pcs',
23
+ basePriceMinor: 100,
24
+ currency: 'EUR',
25
+ status,
26
+ createdAt: 1,
27
+ updatedAt: 1,
28
+ };
29
+ }
30
+
31
+ function listing(changes: Partial<Listing> = {}): Listing {
32
+ return {
33
+ pageIndex: 0,
34
+ pageSize: 50,
35
+ sorting: [{ key: 'name', desc: false }],
36
+ query: '',
37
+ kind: '',
38
+ status: 'active',
39
+ cursors: resetPageCursors(),
40
+ ...changes,
41
+ };
42
+ }
43
+
44
+ describe('catalog client listing state', () => {
45
+ it('walks the cursor stack forward, back, and resets it on a new listing', () => {
46
+ let cursors = resetPageCursors();
47
+ expect(pageCursor(cursors, 0)).toBeNull();
48
+ expect(pageCursor(cursors, 1)).toBeUndefined();
49
+
50
+ cursors = rememberNextCursor(cursors, 0, 'c1');
51
+ expect(pageCursor(cursors, 1)).toBe('c1');
52
+ cursors = rememberNextCursor(cursors, 1, 'c2');
53
+ expect(cursors).toEqual([null, 'c1', 'c2']);
54
+
55
+ /* Going back reuses the stored cursor and drops what lay beyond. */
56
+ cursors = rememberNextCursor(cursors, 0, 'c1-again');
57
+ expect(cursors).toEqual([null, 'c1-again']);
58
+ expect(pageCursor(cursors, 2)).toBeUndefined();
59
+
60
+ /* The last page answers no cursor, so nothing follows it. */
61
+ cursors = rememberNextCursor(cursors, 1, null);
62
+ expect(cursors).toEqual([null, 'c1-again']);
63
+
64
+ expect(resetPageCursors()).toEqual([null]);
65
+ });
66
+
67
+ it('writes a loaded page with an empty selection', () => {
68
+ const loaded = loadedListing(
69
+ listing({ pageIndex: 1, cursors: [null, 'c1'], query: 'bolt' }),
70
+ {
71
+ items: [item('a', 'active')],
72
+ page: { nextCursor: 'c2', limit: 50 },
73
+ },
74
+ );
75
+ expect(loaded).toEqual({
76
+ items: [item('a', 'active')],
77
+ appliedQuery: 'bolt',
78
+ pageIndex: 1,
79
+ cursors: [null, 'c1', 'c2'],
80
+ nextCursor: 'c2',
81
+ selectedIds: new Set(),
82
+ });
83
+ });
84
+
85
+ it('maps the table sort to the server keys', () => {
86
+ expect(catalogSort([])).toEqual({ sort: 'name', direction: 'asc' });
87
+ expect(catalogSort([{ key: 'sku', desc: true }])).toEqual({
88
+ sort: 'sku',
89
+ direction: 'desc',
90
+ });
91
+ expect(catalogSort([{ key: 'updatedAt', desc: false }])).toEqual({
92
+ sort: 'updatedAt',
93
+ direction: 'asc',
94
+ });
95
+ expect(catalogSort([{ key: 'price', desc: false }])).toEqual({
96
+ sort: 'name',
97
+ direction: 'asc',
98
+ });
99
+ });
100
+
101
+ it('names only the selected rows in the status the action leaves', () => {
102
+ const items = [
103
+ item('a', 'active'),
104
+ item('b', 'archived'),
105
+ item('c', 'active'),
106
+ ];
107
+ const selected = new Set(['a', 'b', 'missing']);
108
+ expect(bulkTargets(items, selected, 'active')).toEqual(['a']);
109
+ expect(bulkTargets(items, selected, 'archived')).toEqual(['b']);
110
+ });
111
+
112
+ it('counts bulk outcomes and detects a pending search', () => {
113
+ expect(
114
+ bulkOutcomeCounts([
115
+ { id: 'a', outcome: 'updated' },
116
+ { id: 'b', outcome: 'not-found' },
117
+ { id: 'c', outcome: 'refused', reason: 'X' },
118
+ { id: 'd', outcome: 'updated' },
119
+ ]),
120
+ ).toEqual({ updated: 2, missing: 1, refused: 1 });
121
+ expect(searchPending(' bolt ', 'bolt')).toBe(false);
122
+ expect(searchPending('bolt', '')).toBe(true);
123
+ });
124
+ });