@flowdular/sdk 0.3.1 → 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.
- package/.ai/platform-capabilities.md +2 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- 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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
217
|
-
text: LIST,
|
|
218
|
-
parameters: [tenantId],
|
|
219
|
-
}),
|
|
296
|
+
transaction.query<CatalogListRow>(listStatement(tenantId, read)),
|
|
220
297
|
{ access: 'read', tenantId },
|
|
221
298
|
);
|
|
222
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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()
|
|
165
|
+
allItems(await runtime.service(), 'tenant-a'),
|
|
165
166
|
).resolves.toHaveLength(1);
|
|
166
167
|
await expect(
|
|
167
|
-
(await runtime.service()
|
|
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(
|
|
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
|
+
});
|