@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,46 +1,109 @@
1
1
  import { t } from '@flowdular/sdk/client/i18n';
2
2
  import type { HistoryPage } from '@flowdular/sdk/kernel';
3
3
  import type {
4
+ CatalogBulkOutcome,
4
5
  CatalogItem,
6
+ CatalogItemKind,
7
+ CatalogItemStatus,
8
+ CatalogListSort,
5
9
  CreateCatalogItemInput,
6
10
  UpdateCatalogItemInput,
7
11
  } from '../domain/types.ts';
8
12
 
9
13
  interface ErrorEnvelope {
10
- readonly error?: { readonly message?: string };
14
+ readonly error?: { readonly code?: string; readonly message?: string };
15
+ }
16
+
17
+ export class ApiError extends Error {
18
+ readonly status: number;
19
+ /** Stable server code; '' when the response carried none. */
20
+ readonly code: string;
21
+
22
+ constructor(status: number, message: string, code = '') {
23
+ super(message);
24
+ this.name = 'ApiError';
25
+ this.status = status;
26
+ this.code = code;
27
+ }
11
28
  }
12
29
 
13
30
  async function payload<T>(response: Response): Promise<T> {
14
31
  const value = (await response.json()) as T & ErrorEnvelope;
15
32
  if (!response.ok) {
16
- throw new Error(value.error?.message ?? t('catalog.error.request'));
33
+ throw new ApiError(
34
+ response.status,
35
+ value.error?.message ?? t('catalog.error.request'),
36
+ value.error?.code ?? '',
37
+ );
17
38
  }
18
39
  return value;
19
40
  }
20
41
 
21
- export async function loadCatalogItems(): Promise<readonly CatalogItem[]> {
22
- const response = await fetch('/api/catalog/items', {
42
+ async function post<T>(
43
+ path: string,
44
+ body: unknown,
45
+ csrfToken: string,
46
+ ): Promise<T> {
47
+ const response = await fetch(path, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'content-type': 'application/json',
51
+ 'x-csrf-token': csrfToken,
52
+ },
53
+ credentials: 'same-origin',
54
+ body: JSON.stringify(body),
55
+ });
56
+ return payload<T>(response);
57
+ }
58
+
59
+ export interface CatalogListRequest {
60
+ readonly sort: CatalogListSort;
61
+ readonly direction: 'asc' | 'desc';
62
+ readonly limit: number;
63
+ /** A substring of the name or the SKU; '' narrows nothing. */
64
+ readonly query: string;
65
+ readonly kind: '' | CatalogItemKind;
66
+ readonly status: '' | CatalogItemStatus;
67
+ /** The cursor that opens this page; null asks for the first one. */
68
+ readonly cursor: string | null;
69
+ }
70
+
71
+ export interface CatalogListPage {
72
+ readonly items: readonly CatalogItem[];
73
+ readonly page: { readonly nextCursor: string | null; readonly limit: number };
74
+ }
75
+
76
+ /** One server-sorted, server-narrowed, server-paged listing; the screen narrows nothing. */
77
+ export async function loadCatalogItems(
78
+ request: CatalogListRequest,
79
+ ): Promise<CatalogListPage> {
80
+ const parameters = new URLSearchParams({
81
+ sort: request.sort,
82
+ direction: request.direction,
83
+ limit: String(request.limit),
84
+ });
85
+ if (request.query !== '') parameters.set('q', request.query);
86
+ if (request.kind !== '') parameters.set('kind', request.kind);
87
+ if (request.status !== '') parameters.set('status', request.status);
88
+ if (request.cursor !== null) parameters.set('cursor', request.cursor);
89
+ const response = await fetch('/api/catalog/items?' + parameters.toString(), {
23
90
  headers: { accept: 'application/json' },
24
91
  credentials: 'same-origin',
25
92
  });
26
- return (await payload<{ readonly items: readonly CatalogItem[] }>(response))
27
- .items;
93
+ return payload<CatalogListPage>(response);
28
94
  }
29
95
 
30
96
  export async function createCatalogItem(
31
97
  input: CreateCatalogItemInput,
32
98
  csrfToken: string,
33
99
  ): Promise<CatalogItem> {
34
- const response = await fetch('/api/catalog/items', {
35
- method: 'POST',
36
- headers: {
37
- 'content-type': 'application/json',
38
- 'x-csrf-token': csrfToken,
39
- },
40
- credentials: 'same-origin',
41
- body: JSON.stringify(input),
42
- });
43
- return (await payload<{ readonly item: CatalogItem }>(response)).item;
100
+ return (
101
+ await post<{ readonly item: CatalogItem }>(
102
+ '/api/catalog/items',
103
+ input,
104
+ csrfToken,
105
+ )
106
+ ).item;
44
107
  }
45
108
 
46
109
  async function mutateItem(
@@ -48,16 +111,8 @@ async function mutateItem(
48
111
  body: unknown,
49
112
  csrfToken: string,
50
113
  ): Promise<CatalogItem> {
51
- const response = await fetch(path, {
52
- method: 'POST',
53
- headers: {
54
- 'content-type': 'application/json',
55
- 'x-csrf-token': csrfToken,
56
- },
57
- credentials: 'same-origin',
58
- body: JSON.stringify(body),
59
- });
60
- return (await payload<{ readonly item: CatalogItem }>(response)).item;
114
+ return (await post<{ readonly item: CatalogItem }>(path, body, csrfToken))
115
+ .item;
61
116
  }
62
117
 
63
118
  export function updateCatalogItem(
@@ -81,20 +136,53 @@ export function restoreCatalogItem(
81
136
  return mutateItem('/api/catalog/items/restore', { id }, csrfToken);
82
137
  }
83
138
 
139
+ type OutcomesResponse = { readonly outcomes: readonly CatalogBulkOutcome[] };
140
+
141
+ /* The selected rows, one outcome per id. */
142
+ export async function archiveCatalogItems(
143
+ ids: readonly string[],
144
+ csrfToken: string,
145
+ ): Promise<readonly CatalogBulkOutcome[]> {
146
+ return (
147
+ await post<OutcomesResponse>(
148
+ '/api/catalog/items/archive-many',
149
+ { ids },
150
+ csrfToken,
151
+ )
152
+ ).outcomes;
153
+ }
154
+
155
+ export async function restoreCatalogItems(
156
+ ids: readonly string[],
157
+ csrfToken: string,
158
+ ): Promise<readonly CatalogBulkOutcome[]> {
159
+ return (
160
+ await post<OutcomesResponse>(
161
+ '/api/catalog/items/restore-many',
162
+ { ids },
163
+ csrfToken,
164
+ )
165
+ ).outcomes;
166
+ }
167
+
84
168
  export async function deleteCatalogItem(
85
169
  id: string,
86
170
  csrfToken: string,
87
171
  ): Promise<void> {
88
- const response = await fetch('/api/catalog/items/delete', {
89
- method: 'POST',
90
- headers: {
91
- 'content-type': 'application/json',
92
- 'x-csrf-token': csrfToken,
93
- },
94
- credentials: 'same-origin',
95
- body: JSON.stringify({ id }),
96
- });
97
- await payload<{ readonly deleted: true }>(response);
172
+ await post<{ readonly deleted: true }>(
173
+ '/api/catalog/items/delete',
174
+ { id },
175
+ csrfToken,
176
+ );
177
+ }
178
+
179
+ /* exports.core owns the job, its file and the screen that hands it over, so the
180
+ Catalog screen only starts one. */
181
+ export async function startListExport(
182
+ list: string,
183
+ csrfToken: string,
184
+ ): Promise<void> {
185
+ await post<unknown>('/api/exports/start', { list }, csrfToken);
98
186
  }
99
187
 
100
188
  export async function loadCatalogItemHistory(
@@ -13,6 +13,10 @@ export interface CatalogClientContributionOptions {
13
13
  readonly scopes: readonly string[];
14
14
  }
15
15
 
16
+ /* exports.core's own permission; the screen shows the export button only to a
17
+ principal that may start a job there. */
18
+ const EXPORTS_MANAGE = 'exports.lists.manage';
19
+
16
20
  export function createCatalogClientContribution(
17
21
  options: CatalogClientContributionOptions,
18
22
  ): ModuleClientContribution {
@@ -45,6 +49,7 @@ export function createCatalogClientContribution(
45
49
  <CatalogView
46
50
  csrfToken={options.csrfToken}
47
51
  canManage={options.scopes.includes(CATALOG_PERMISSIONS.manage)}
52
+ canExport={options.scopes.includes(EXPORTS_MANAGE)}
48
53
  />,
49
54
  },
50
55
  ],
@@ -1,12 +1,64 @@
1
1
  import { cell, createStore } from 'segment-state';
2
2
  import type { HistoryEntry } from '@flowdular/sdk/kernel';
3
- import type { CatalogItem } from '../domain/types.ts';
3
+ import type { TableSort } from '@flowdular/sdk/ui';
4
+ import type {
5
+ CatalogBulkOutcome,
6
+ CatalogItem,
7
+ CatalogItemKind,
8
+ CatalogItemStatus,
9
+ CatalogListSort,
10
+ } from '../domain/types.ts';
11
+ import type { CatalogListPage } from './api.ts';
12
+
13
+ /** `denied` is a 403 the shell could not hide; `error` is everything else. */
14
+ export type CatalogStatus =
15
+ | 'idle'
16
+ | 'loading'
17
+ | 'submitting'
18
+ | 'denied'
19
+ | 'error';
20
+
21
+ export type CatalogKindFilter = '' | CatalogItemKind;
22
+ export type CatalogStatusFilter = '' | CatalogItemStatus;
23
+
24
+ export const DEFAULT_CATALOG_SORTING: readonly TableSort[] = [
25
+ { key: 'name', desc: false },
26
+ ];
27
+
28
+ /** The screen opens on the active items; archived ones are a filter away. */
29
+ export const DEFAULT_STATUS_FILTER: CatalogStatusFilter = 'active';
30
+
31
+ /**
32
+ * The cursor that opened each page the reader has visited: page 0 has none,
33
+ * and page n holds the cursor page n - 1 answered. Going back reuses the stored
34
+ * cursor; a page past the stack cannot be opened.
35
+ */
36
+ export type PageCursors = readonly (string | null)[];
37
+
38
+ /** One listing as the server answers it: the page, its order and its filters. */
39
+ export interface Listing {
40
+ readonly pageIndex: number;
41
+ readonly pageSize: number;
42
+ readonly sorting: readonly TableSort[];
43
+ readonly query: string;
44
+ readonly kind: CatalogKindFilter;
45
+ readonly status: CatalogStatusFilter;
46
+ readonly cursors: PageCursors;
47
+ }
4
48
 
5
49
  export function createCatalogClientState() {
6
50
  const store = createStore({
7
51
  items: cell<readonly CatalogItem[]>([]),
8
52
  query: '',
9
- includeArchived: false,
53
+ /** The term the rows on screen answer; typing moves `query` ahead of it. */
54
+ appliedQuery: '',
55
+ kindFilter: cell<CatalogKindFilter>(''),
56
+ statusFilter: cell<CatalogStatusFilter>(DEFAULT_STATUS_FILTER),
57
+ sorting: cell<readonly TableSort[]>(DEFAULT_CATALOG_SORTING),
58
+ pageIndex: 0,
59
+ pageSize: 50,
60
+ cursors: cell<PageCursors>([null]),
61
+ nextCursor: cell<string | null>(null),
10
62
  filtersOpen: false,
11
63
  formOpen: false,
12
64
  deleteConfirmOpen: false,
@@ -16,9 +68,123 @@ export function createCatalogClientState() {
16
68
  historyEntries: cell<readonly HistoryEntry[]>([]),
17
69
  historyLoading: false,
18
70
  historyError: '',
19
- status: cell<'idle' | 'loading' | 'submitting'>('idle'),
71
+ status: cell<CatalogStatus>('idle'),
20
72
  error: '',
21
73
  formSession: 0,
74
+ /* Row ids checked in the table; the screen owns it, the table never
75
+ clears it. */
76
+ selectedIds: cell<ReadonlySet<string>>(new Set()),
77
+ confirmArchiveMany: false,
78
+ exportStarted: false,
22
79
  });
23
80
  return { store, state: store.state };
24
81
  }
82
+
83
+ /** The server's sort key and direction behind the table's sorting state. */
84
+ export function catalogSort(sorts: readonly TableSort[]): {
85
+ readonly sort: CatalogListSort;
86
+ readonly direction: 'asc' | 'desc';
87
+ } {
88
+ const first = sorts[0] ?? DEFAULT_CATALOG_SORTING[0]!;
89
+ return {
90
+ sort: first.key === 'sku' || first.key === 'updatedAt' ? first.key : 'name',
91
+ direction: first.desc ? 'desc' : 'asc',
92
+ };
93
+ }
94
+
95
+ /** The cursor that opens `pageIndex`, or undefined when the reader never reached it. */
96
+ export function pageCursor(
97
+ cursors: PageCursors,
98
+ pageIndex: number,
99
+ ): string | null | undefined {
100
+ return pageIndex < cursors.length ? cursors[pageIndex] : undefined;
101
+ }
102
+
103
+ /**
104
+ * Records what the page at `pageIndex` answered: the cursor of the page after
105
+ * it, or nothing when it was the last. Pages beyond the next one are dropped,
106
+ * since the set may have changed under them.
107
+ */
108
+ export function rememberNextCursor(
109
+ cursors: PageCursors,
110
+ pageIndex: number,
111
+ nextCursor: string | null,
112
+ ): PageCursors {
113
+ const kept = cursors.slice(0, pageIndex + 1);
114
+ return nextCursor === null ? kept : [...kept, nextCursor];
115
+ }
116
+
117
+ export function resetPageCursors(): PageCursors {
118
+ return [null];
119
+ }
120
+
121
+ /**
122
+ * What a loaded page writes to the screen. The selection is emptied with every
123
+ * listing, on a page, sort or filter change as much as on a refresh, because a
124
+ * selected id names a row the reader saw on the page that is being replaced.
125
+ */
126
+ export function loadedListing(
127
+ next: Listing,
128
+ page: CatalogListPage,
129
+ ): {
130
+ readonly items: readonly CatalogItem[];
131
+ readonly appliedQuery: string;
132
+ readonly pageIndex: number;
133
+ readonly cursors: PageCursors;
134
+ readonly nextCursor: string | null;
135
+ readonly selectedIds: ReadonlySet<string>;
136
+ } {
137
+ return {
138
+ items: page.items,
139
+ appliedQuery: next.query,
140
+ pageIndex: next.pageIndex,
141
+ cursors: rememberNextCursor(
142
+ next.cursors,
143
+ next.pageIndex,
144
+ page.page.nextCursor,
145
+ ),
146
+ nextCursor: page.page.nextCursor,
147
+ selectedIds: new Set(),
148
+ };
149
+ }
150
+
151
+ export function searchPending(query: string, applied: string): boolean {
152
+ return query.trim() !== applied;
153
+ }
154
+
155
+ /** The selected rows a lifecycle action may name: on screen and in the status it leaves. */
156
+ export function bulkTargets(
157
+ items: readonly CatalogItem[],
158
+ selected: ReadonlySet<string>,
159
+ from: CatalogItemStatus,
160
+ ): readonly string[] {
161
+ return items
162
+ .filter((item) => selected.has(item.id) && item.status === from)
163
+ .map((item) => item.id);
164
+ }
165
+
166
+ /** How many ids each outcome covered, for the toast after a bulk action. */
167
+ export function bulkOutcomeCounts(outcomes: readonly CatalogBulkOutcome[]): {
168
+ readonly updated: number;
169
+ readonly missing: number;
170
+ readonly refused: number;
171
+ } {
172
+ let updated = 0;
173
+ let missing = 0;
174
+ let refused = 0;
175
+ for (const entry of outcomes) {
176
+ if (entry.outcome === 'updated') updated += 1;
177
+ else if (entry.outcome === 'not-found') missing += 1;
178
+ else refused += 1;
179
+ }
180
+ return { updated, missing, refused };
181
+ }
182
+
183
+ /* The server ordered the page, so a changed row keeps its place until the next
184
+ read; a row that is not on this page changes nothing here. */
185
+ export function replaceItem(
186
+ items: readonly CatalogItem[],
187
+ item: CatalogItem,
188
+ ): readonly CatalogItem[] {
189
+ return items.map((current) => (current.id === item.id ? item : current));
190
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The id exports.core holds this module's items list under: the module id
3
+ * followed by the list key. The screen that starts a job and the server that
4
+ * declares the list name one string, without the client bundle importing
5
+ * `@flowdular/sdk/server` to read it.
6
+ */
7
+ export const CATALOG_ITEMS_EXPORT_LIST_ID = 'catalog.core.items';
@@ -11,6 +11,26 @@ export interface CatalogItem {
11
11
  readonly currency: string;
12
12
  readonly status: 'active' | 'archived';
13
13
  readonly createdAt: number;
14
+ readonly updatedAt: number;
15
+ }
16
+
17
+ export type CatalogItemStatus = CatalogItem['status'];
18
+
19
+ /** The orders the items list answers; each one ends in the item id. */
20
+ export const CATALOG_LIST_SORTS = ['name', 'sku', 'updatedAt'] as const;
21
+ export type CatalogListSort = (typeof CATALOG_LIST_SORTS)[number];
22
+
23
+ /** Characters a list search term may carry. */
24
+ export const CATALOG_SEARCH_LENGTH = 120;
25
+
26
+ /** Ids one bulk lifecycle call may name. */
27
+ export const CATALOG_BULK_LIMIT = 100;
28
+
29
+ export interface CatalogBulkOutcome {
30
+ readonly id: string;
31
+ readonly outcome: 'updated' | 'not-found' | 'refused';
32
+ /** The stable service code behind a refusal. */
33
+ readonly reason?: string;
14
34
  }
15
35
 
16
36
  export interface CreateCatalogItemInput {
@@ -2,9 +2,14 @@ import type {
2
2
  PlatformServerComposition,
3
3
  PlatformServerContext,
4
4
  } from '@flowdular/sdk/modules/auth/server';
5
+ import {
6
+ EXPORT_LISTS_CAPABILITY,
7
+ type ExportLists,
8
+ } from '@flowdular/sdk/modules/exports';
5
9
  import { platformVariableRegistry } from '@flowdular/sdk/kernel';
6
10
  import { registerCatalogVariableSource } from './domain/variables.ts';
7
11
  import { catalogDataClasses } from './services/data-classes.ts';
12
+ import { createCatalogItemsListExport } from './services/item-export.ts';
8
13
  import {
9
14
  catalogAgentTools,
10
15
  createCatalogRoutes,
@@ -30,8 +35,23 @@ export function createServerComposition(
30
35
  tools,
31
36
  );
32
37
  context.dataClasses.declare(catalogDataClasses(() => runtime.service()));
38
+ /* exports.core is an optional requirement, so its registry may not exist
39
+ yet while this module composes; it is asked for here and again at start,
40
+ and a deployment without exports.core registers nothing. */
41
+ let registered = false;
42
+ const registerLists = (): void => {
43
+ if (registered) return;
44
+ const lists = context.capabilities.get<ExportLists>(
45
+ EXPORT_LISTS_CAPABILITY,
46
+ );
47
+ if (!lists) return;
48
+ registered = true;
49
+ lists.register('catalog.core', [createCatalogItemsListExport(runtime)]);
50
+ };
51
+ registerLists();
33
52
  return {
34
53
  routes: createCatalogRoutes(context.auth, runtime),
54
+ start: () => registerLists(),
35
55
  dispose: () => runtime.dispose(),
36
56
  };
37
57
  }
@@ -7,14 +7,22 @@ import {
7
7
  type HistoryPage,
8
8
  type HistoryRequest,
9
9
  } from '@flowdular/sdk/kernel';
10
- import type {
11
- CatalogItem,
12
- CatalogItemKind,
13
- CreateCatalogItemInput,
14
- UpdateCatalogItemInput,
10
+ import {
11
+ CATALOG_BULK_LIMIT,
12
+ CATALOG_LIST_SORTS,
13
+ CATALOG_SEARCH_LENGTH,
14
+ type CatalogBulkOutcome,
15
+ type CatalogItem,
16
+ type CatalogItemKind,
17
+ type CatalogItemStatus,
18
+ type CatalogListSort,
19
+ type CreateCatalogItemInput,
20
+ type UpdateCatalogItemInput,
15
21
  } from '../domain/types.ts';
16
22
  import {
17
23
  DuplicateSkuError,
24
+ type CatalogListPage,
25
+ type CatalogPageKeyset,
18
26
  type CatalogRepository,
19
27
  type ExportCursor,
20
28
  } from './repository.ts';
@@ -31,6 +39,32 @@ export interface CatalogIdempotencyRequest {
31
39
  /** Rows one export read carries; the whole tenant is walked page by page. */
32
40
  export const EXPORT_PAGE = 200;
33
41
 
42
+ /** The default page of the items list, and the most a read may ask for. */
43
+ export const LIST_PAGE_LIMIT = 50;
44
+ export const LIST_PAGE_MAX_LIMIT = 200;
45
+
46
+ /** One list page as a caller asks for it, before the service validates it. */
47
+ export interface CatalogListInput {
48
+ readonly sort: CatalogListSort;
49
+ readonly direction: 'asc' | 'desc';
50
+ readonly kind: CatalogItemKind | null;
51
+ readonly status: CatalogItemStatus | null;
52
+ /** A substring of the name or the SKU; '' narrows nothing. */
53
+ readonly search: string;
54
+ readonly limit: number;
55
+ readonly after: CatalogPageKeyset | null;
56
+ }
57
+
58
+ export const FIRST_LIST_PAGE: CatalogListInput = {
59
+ sort: 'name',
60
+ direction: 'asc',
61
+ kind: null,
62
+ status: null,
63
+ search: '',
64
+ limit: LIST_PAGE_MAX_LIMIT,
65
+ after: null,
66
+ };
67
+
34
68
  export class CatalogServiceError extends Error {
35
69
  constructor(
36
70
  readonly code: string,
@@ -68,6 +102,16 @@ function itemKind(value: CatalogItemKind): CatalogItemKind {
68
102
  return value;
69
103
  }
70
104
 
105
+ function itemStatus(value: CatalogItemStatus): CatalogItemStatus {
106
+ if (value !== 'active' && value !== 'archived') {
107
+ throw new CatalogServiceError(
108
+ 'INVALID_INPUT',
109
+ 'status must be active or archived.',
110
+ );
111
+ }
112
+ return value;
113
+ }
114
+
71
115
  function trustedActor(actor: Actor): Actor {
72
116
  const normalized = normalizeActor(actor);
73
117
  if (!normalized) {
@@ -82,8 +126,52 @@ function trustedActor(actor: Actor): Actor {
82
126
  export class CatalogService {
83
127
  constructor(private readonly repository: CatalogRepository) {}
84
128
 
85
- async list(tenantId: string): Promise<readonly CatalogItem[]> {
86
- return await this.repository.list(bounded(tenantId, 'tenantId', 1, 128));
129
+ /** One server-ordered, server-narrowed page; the only listing the module answers. */
130
+ async listPage(
131
+ tenantId: string,
132
+ input: CatalogListInput,
133
+ ): Promise<CatalogListPage> {
134
+ if (!(CATALOG_LIST_SORTS as readonly string[]).includes(input.sort)) {
135
+ throw new CatalogServiceError(
136
+ 'INVALID_INPUT',
137
+ `sort must be one of ${CATALOG_LIST_SORTS.join(', ')}.`,
138
+ );
139
+ }
140
+ if (input.direction !== 'asc' && input.direction !== 'desc') {
141
+ throw new CatalogServiceError(
142
+ 'INVALID_INPUT',
143
+ 'direction must be asc or desc.',
144
+ );
145
+ }
146
+ if (
147
+ !Number.isSafeInteger(input.limit) ||
148
+ input.limit < 1 ||
149
+ input.limit > LIST_PAGE_MAX_LIMIT
150
+ ) {
151
+ throw new CatalogServiceError(
152
+ 'INVALID_INPUT',
153
+ `limit must be an integer between 1 and ${LIST_PAGE_MAX_LIMIT}.`,
154
+ );
155
+ }
156
+ const search = bounded(input.search, 'search', 0, CATALOG_SEARCH_LENGTH);
157
+ return await this.repository.listPage(
158
+ bounded(tenantId, 'tenantId', 1, 128),
159
+ {
160
+ sort: input.sort,
161
+ direction: input.direction,
162
+ kind: input.kind === null ? null : itemKind(input.kind),
163
+ status: input.status === null ? null : itemStatus(input.status),
164
+ term: search === '' ? null : search.toLocaleLowerCase('en-US'),
165
+ limit: input.limit,
166
+ after:
167
+ input.after === null
168
+ ? null
169
+ : {
170
+ sortValue: bounded(input.after.sortValue, 'cursor', 0, 512),
171
+ id: bounded(input.after.id, 'cursor', 1, 128),
172
+ },
173
+ },
174
+ );
87
175
  }
88
176
 
89
177
  async get(tenantId: string, id: string): Promise<CatalogItem | null> {
@@ -160,6 +248,7 @@ export class CatalogService {
160
248
  'currency must be a three-letter ISO code.',
161
249
  );
162
250
  }
251
+ const now = Date.now();
163
252
  return {
164
253
  id: randomUUID(),
165
254
  tenantId: bounded(tenantId, 'tenantId', 1, 128),
@@ -170,7 +259,8 @@ export class CatalogService {
170
259
  basePriceMinor: input.basePriceMinor,
171
260
  currency,
172
261
  status: 'active',
173
- createdAt: Date.now(),
262
+ createdAt: now,
263
+ updatedAt: now,
174
264
  };
175
265
  }
176
266
 
@@ -246,6 +336,51 @@ export class CatalogService {
246
336
  return await this.changeStatus(tenantId, id, 'active', actor);
247
337
  }
248
338
 
339
+ archiveMany(
340
+ tenantId: string,
341
+ ids: readonly string[],
342
+ actor: Actor,
343
+ ): Promise<readonly CatalogBulkOutcome[]> {
344
+ return this.each(ids, (id) => this.archive(tenantId, id, actor));
345
+ }
346
+
347
+ restoreMany(
348
+ tenantId: string,
349
+ ids: readonly string[],
350
+ actor: Actor,
351
+ ): Promise<readonly CatalogBulkOutcome[]> {
352
+ return this.each(ids, (id) => this.restore(tenantId, id, actor));
353
+ }
354
+
355
+ /* One outcome per id through the single-row path, so every accepted
356
+ transition keeps its own history row and a refused id fails no other. */
357
+ private async each(
358
+ ids: readonly string[],
359
+ write: (id: string) => Promise<unknown>,
360
+ ): Promise<readonly CatalogBulkOutcome[]> {
361
+ if (ids.length < 1 || ids.length > CATALOG_BULK_LIMIT) {
362
+ throw new CatalogServiceError(
363
+ 'INVALID_INPUT',
364
+ `ids must name between 1 and ${CATALOG_BULK_LIMIT} items.`,
365
+ );
366
+ }
367
+ const outcomes: CatalogBulkOutcome[] = [];
368
+ for (const id of ids) {
369
+ try {
370
+ await write(id);
371
+ outcomes.push({ id, outcome: 'updated' });
372
+ } catch (error) {
373
+ if (!(error instanceof CatalogServiceError)) throw error;
374
+ outcomes.push(
375
+ error.code === 'CATALOG_ITEM_NOT_FOUND'
376
+ ? { id, outcome: 'not-found' }
377
+ : { id, outcome: 'refused', reason: error.code },
378
+ );
379
+ }
380
+ }
381
+ return outcomes;
382
+ }
383
+
249
384
  async delete(tenantId: string, id: string, actor: Actor): Promise<void> {
250
385
  const trustedTenantId = bounded(tenantId, 'tenantId', 1, 128);
251
386
  const trustedId = bounded(id, 'id', 1, 128);