create-flowdular 0.2.4 → 0.2.6
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/README.md +11 -0
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +203 -0
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +90 -0
- package/agent-template/.agents/skills/auto-review/SKILL.md +103 -0
- package/agent-template/.agents/skills/bug-hunt/SKILL.md +104 -0
- package/agent-template/.agents/skills/business-agent-design/SKILL.md +182 -0
- package/agent-template/.agents/skills/cli-extension/SKILL.md +108 -0
- package/agent-template/.agents/skills/core-extend/SKILL.md +99 -0
- package/agent-template/.agents/skills/database-adapter/SKILL.md +198 -0
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +161 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +171 -0
- package/agent-template/.agents/skills/module-update/SKILL.md +91 -0
- package/agent-template/.agents/skills/perf-audit/SKILL.md +98 -0
- package/agent-template/.agents/skills/release-eject-pr/SKILL.md +107 -0
- package/agent-template/.agents/skills/spec-approval/SKILL.md +106 -0
- package/agent-template/.agents/skills/test-hardening/SKILL.md +79 -0
- package/agent-template/.agents/skills/translations-i18n/SKILL.md +78 -0
- package/agent-template/.agents/skills/ux-design/SKILL.md +92 -0
- package/agent-template/.agents/skills/variables/SKILL.md +156 -0
- package/agent-template/.agents/skills/workflow-development/SKILL.md +192 -0
- package/agent-template/.ai/README.md +62 -0
- package/agent-template/.ai/agents/README.md +27 -0
- package/agent-template/.ai/agents/module-executor.md +36 -0
- package/agent-template/.ai/agents/reviewer.md +23 -0
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +31 -0
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +36 -0
- package/agent-template/.ai/agents/sandbox/business-manager.md +23 -0
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +27 -0
- package/agent-template/.ai/agents/sandbox/ux-designer.md +23 -0
- package/agent-template/.ai/agents/spec-author.md +29 -0
- package/agent-template/.ai/blueprints/add-migration/README.md +5 -0
- package/agent-template/.ai/blueprints/add-migration/allowed-paths.yaml +23 -0
- package/agent-template/.ai/blueprints/add-migration/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/add-migration/examples/invalid/input-destructive.json +6 -0
- package/agent-template/.ai/blueprints/add-migration/examples/invalid/plan-unnumbered-file.json +9 -0
- package/agent-template/.ai/blueprints/add-migration/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/add-migration/examples/valid/plan.json +9 -0
- package/agent-template/.ai/blueprints/add-migration/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/add-migration/input.schema.json +23 -0
- package/agent-template/.ai/blueprints/add-migration/plan.schema.json +54 -0
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +18 -0
- package/agent-template/.ai/blueprints/add-migration/spec-requirements.yaml +13 -0
- package/agent-template/.ai/blueprints/add-migration/steps.yaml +62 -0
- package/agent-template/.ai/blueprints/author-spec/README.md +5 -0
- package/agent-template/.ai/blueprints/author-spec/allowed-paths.yaml +7 -0
- package/agent-template/.ai/blueprints/author-spec/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/author-spec/examples/invalid/input-missing-outcome.json +5 -0
- package/agent-template/.ai/blueprints/author-spec/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/author-spec/gates.yaml +13 -0
- package/agent-template/.ai/blueprints/author-spec/input.schema.json +20 -0
- package/agent-template/.ai/blueprints/author-spec/plan.schema.json +14 -0
- package/agent-template/.ai/blueprints/author-spec/required-files.yaml +6 -0
- package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +35 -0
- package/agent-template/.ai/blueprints/author-spec/steps.yaml +28 -0
- package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +45 -0
- package/agent-template/.ai/blueprints/bug-fix/README.md +5 -0
- package/agent-template/.ai/blueprints/bug-fix/allowed-paths.yaml +29 -0
- package/agent-template/.ai/blueprints/bug-fix/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/invalid/input-no-symptom.json +4 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/invalid/plan-no-test.json +8 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/valid/plan.json +8 -0
- package/agent-template/.ai/blueprints/bug-fix/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/bug-fix/input.schema.json +25 -0
- package/agent-template/.ai/blueprints/bug-fix/plan.schema.json +53 -0
- package/agent-template/.ai/blueprints/bug-fix/required-files.yaml +7 -0
- package/agent-template/.ai/blueprints/bug-fix/spec-requirements.yaml +7 -0
- package/agent-template/.ai/blueprints/bug-fix/steps.yaml +51 -0
- package/agent-template/.ai/blueprints/core-extend/README.md +5 -0
- package/agent-template/.ai/blueprints/core-extend/allowed-paths.yaml +49 -0
- package/agent-template/.ai/blueprints/core-extend/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/core-extend/examples/invalid/input-unknown-package.json +5 -0
- package/agent-template/.ai/blueprints/core-extend/examples/invalid/plan-missing-gates.json +7 -0
- package/agent-template/.ai/blueprints/core-extend/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/core-extend/examples/valid/plan.json +10 -0
- package/agent-template/.ai/blueprints/core-extend/gates.yaml +16 -0
- package/agent-template/.ai/blueprints/core-extend/input.schema.json +54 -0
- package/agent-template/.ai/blueprints/core-extend/plan.schema.json +39 -0
- package/agent-template/.ai/blueprints/core-extend/required-files.yaml +36 -0
- package/agent-template/.ai/blueprints/core-extend/spec-requirements.yaml +17 -0
- package/agent-template/.ai/blueprints/core-extend/steps.yaml +54 -0
- package/agent-template/.ai/blueprints/edit-module/README.md +9 -0
- package/agent-template/.ai/blueprints/edit-module/allowed-paths.yaml +27 -0
- package/agent-template/.ai/blueprints/edit-module/blueprint.json +20 -0
- package/agent-template/.ai/blueprints/edit-module/examples/invalid/input-unknown-change.json +5 -0
- package/agent-template/.ai/blueprints/edit-module/examples/invalid/plan-touches-platform.json +15 -0
- package/agent-template/.ai/blueprints/edit-module/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/edit-module/examples/valid/plan.json +25 -0
- package/agent-template/.ai/blueprints/edit-module/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/edit-module/input.schema.json +31 -0
- package/agent-template/.ai/blueprints/edit-module/plan.schema.json +65 -0
- package/agent-template/.ai/blueprints/edit-module/required-files.yaml +80 -0
- package/agent-template/.ai/blueprints/edit-module/spec-requirements.yaml +15 -0
- package/agent-template/.ai/blueprints/edit-module/steps.yaml +115 -0
- package/agent-template/.ai/blueprints/new-module/README.md +7 -0
- package/agent-template/.ai/blueprints/new-module/allowed-paths.yaml +27 -0
- package/agent-template/.ai/blueprints/new-module/blueprint.json +20 -0
- package/agent-template/.ai/blueprints/new-module/examples/invalid/input-spec-outside-modules.json +4 -0
- package/agent-template/.ai/blueprints/new-module/examples/invalid/plan-unknown-gate.json +8 -0
- package/agent-template/.ai/blueprints/new-module/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/new-module/examples/valid/plan.json +22 -0
- package/agent-template/.ai/blueprints/new-module/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/new-module/input.schema.json +21 -0
- package/agent-template/.ai/blueprints/new-module/plan.schema.json +58 -0
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +73 -0
- package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +30 -0
- package/agent-template/.ai/blueprints/new-module/steps.yaml +138 -0
- package/agent-template/.ai/blueprints/release/README.md +5 -0
- package/agent-template/.ai/blueprints/release/allowed-paths.yaml +19 -0
- package/agent-template/.ai/blueprints/release/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/release/examples/invalid/input-bad-version.json +4 -0
- package/agent-template/.ai/blueprints/release/examples/invalid/plan-bad-branch.json +7 -0
- package/agent-template/.ai/blueprints/release/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/release/examples/valid/plan.json +20 -0
- package/agent-template/.ai/blueprints/release/gates.yaml +20 -0
- package/agent-template/.ai/blueprints/release/input.schema.json +24 -0
- package/agent-template/.ai/blueprints/release/plan.schema.json +46 -0
- package/agent-template/.ai/blueprints/release/required-files.yaml +19 -0
- package/agent-template/.ai/blueprints/release/spec-requirements.yaml +8 -0
- package/agent-template/.ai/blueprints/release/steps.yaml +47 -0
- package/agent-template/.ai/blueprints/security-review/README.md +5 -0
- package/agent-template/.ai/blueprints/security-review/allowed-paths.yaml +6 -0
- package/agent-template/.ai/blueprints/security-review/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/security-review/examples/invalid/input-unknown-kind.json +4 -0
- package/agent-template/.ai/blueprints/security-review/examples/invalid/plan-finding-without-scenario.json +14 -0
- package/agent-template/.ai/blueprints/security-review/examples/valid/input.json +4 -0
- package/agent-template/.ai/blueprints/security-review/examples/valid/plan.json +19 -0
- package/agent-template/.ai/blueprints/security-review/gates.yaml +22 -0
- package/agent-template/.ai/blueprints/security-review/input.schema.json +20 -0
- package/agent-template/.ai/blueprints/security-review/plan.schema.json +65 -0
- package/agent-template/.ai/blueprints/security-review/required-files.yaml +6 -0
- package/agent-template/.ai/blueprints/security-review/spec-requirements.yaml +9 -0
- package/agent-template/.ai/blueprints/security-review/steps.yaml +38 -0
- package/agent-template/.ai/examples/README.md +8 -0
- package/agent-template/.ai/examples/bad/client-imports-server/README.md +20 -0
- package/agent-template/.ai/examples/bad/client-imports-server/api.ts +12 -0
- package/agent-template/.ai/examples/bad/missing-acl/README.md +23 -0
- package/agent-template/.ai/examples/bad/missing-acl/endpoints.ts +12 -0
- package/agent-template/.ai/examples/bad/tenant-from-body/README.md +19 -0
- package/agent-template/.ai/examples/bad/tenant-from-body/endpoints.ts +33 -0
- package/agent-template/.ai/examples/client-contribution/CustomerListView.tsrx +34 -0
- package/agent-template/.ai/examples/client-contribution/README.md +11 -0
- package/agent-template/.ai/examples/client-contribution/contribution.tsrx +48 -0
- package/agent-template/.ai/examples/client-contribution/index.ts +20 -0
- package/agent-template/.ai/examples/client-contribution/permissions.ts +8 -0
- package/agent-template/.ai/examples/customer-cli-extension/README.md +14 -0
- package/agent-template/.ai/examples/customer-cli-extension/commands.json +17 -0
- package/agent-template/.ai/examples/customer-cli-extension/index.ts +36 -0
- package/agent-template/.ai/examples/module-create/task-packet.json +11 -0
- package/agent-template/.ai/guides/application-development.md +97 -0
- package/agent-template/.ai/policies/capabilities.yaml +164 -0
- package/agent-template/.ai/policies/model-routing.yaml +72 -0
- package/agent-template/.ai/policies/path-ownership.yaml +65 -0
- package/agent-template/.ai/policies/task-budgets.yaml +37 -0
- package/agent-template/.ai/references/catalog/LICENSE +21 -0
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.down.sql +2 -0
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +21 -0
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +20 -0
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +36 -0
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +19 -0
- package/agent-template/.ai/references/catalog/migrations/README.md +3 -0
- package/agent-template/.ai/references/catalog/module.json +27 -0
- package/agent-template/.ai/references/catalog/package.json +49 -0
- package/agent-template/.ai/references/catalog/spec/module.yaml +86 -0
- package/agent-template/.ai/references/catalog/src/acl/permissions.ts +6 -0
- package/agent-template/.ai/references/catalog/src/agent/tools.ts +164 -0
- package/agent-template/.ai/references/catalog/src/api/endpoints.ts +243 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogHistoryDrawer.tsrx +123 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +190 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +473 -0
- package/agent-template/.ai/references/catalog/src/client/api.ts +111 -0
- package/agent-template/.ai/references/catalog/src/client/contribution.tsrx +61 -0
- package/agent-template/.ai/references/catalog/src/client/index.ts +18 -0
- package/agent-template/.ai/references/catalog/src/client/navigation-copy.ts +9 -0
- package/agent-template/.ai/references/catalog/src/client/state.ts +24 -0
- package/agent-template/.ai/references/catalog/src/domain/types.ts +32 -0
- package/agent-template/.ai/references/catalog/src/domain/variables.ts +111 -0
- package/agent-template/.ai/references/catalog/src/index.ts +31 -0
- package/agent-template/.ai/references/catalog/src/platform.ts +35 -0
- package/agent-template/.ai/references/catalog/src/server/index.ts +4 -0
- package/agent-template/.ai/references/catalog/src/server/runtime.ts +86 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +306 -0
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +440 -0
- package/agent-template/.ai/references/catalog/src/services/index.ts +4 -0
- package/agent-template/.ai/references/catalog/src/services/migration.ts +171 -0
- package/agent-template/.ai/references/catalog/src/services/repository.ts +36 -0
- package/agent-template/.ai/references/catalog/src/services/target-idempotency.ts +59 -0
- package/agent-template/.ai/references/catalog/tests/agent-tools.test.ts +277 -0
- package/agent-template/.ai/references/catalog/tests/endpoints.test.ts +320 -0
- package/agent-template/.ai/references/catalog/tests/idempotency.test.ts +297 -0
- package/agent-template/.ai/references/catalog/tests/migrations.test.ts +149 -0
- package/agent-template/.ai/references/catalog/tests/module.test.ts +271 -0
- package/agent-template/.ai/references/catalog/tests/support/database.ts +76 -0
- package/agent-template/.ai/references/catalog/translations/en.json +101 -0
- package/agent-template/.ai/references/catalog/translations/pl.json +101 -0
- package/agent-template/.ai/references/catalog/tsconfig.json +15 -0
- package/agent-template/.ai/references/catalog/vitest.config.ts +16 -0
- package/agent-template/.ai/references/catalog.provenance.json +55 -0
- package/agent-template/.ai/rules/flowdular.md +86 -0
- package/agent-template/.ai/skills/README.md +36 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +209 -0
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +96 -0
- package/agent-template/.ai/skills/auto-review/SKILL.md +112 -0
- package/agent-template/.ai/skills/bug-hunt/SKILL.md +110 -0
- package/agent-template/.ai/skills/business-agent-design/SKILL.md +188 -0
- package/agent-template/.ai/skills/cli-extension/SKILL.md +114 -0
- package/agent-template/.ai/skills/core-extend/SKILL.md +104 -0
- package/agent-template/.ai/skills/database-adapter/SKILL.md +204 -0
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +167 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +180 -0
- package/agent-template/.ai/skills/module-update/SKILL.md +100 -0
- package/agent-template/.ai/skills/perf-audit/SKILL.md +105 -0
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +113 -0
- package/agent-template/.ai/skills/spec-approval/SKILL.md +112 -0
- package/agent-template/.ai/skills/test-hardening/SKILL.md +86 -0
- package/agent-template/.ai/skills/translations-i18n/SKILL.md +85 -0
- package/agent-template/.ai/skills/ux-design/SKILL.md +97 -0
- package/agent-template/.ai/skills/variables/SKILL.md +164 -0
- package/agent-template/.ai/skills/workflow-development/SKILL.md +199 -0
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +203 -0
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +90 -0
- package/agent-template/.claude/skills/auto-review/SKILL.md +103 -0
- package/agent-template/.claude/skills/bug-hunt/SKILL.md +104 -0
- package/agent-template/.claude/skills/business-agent-design/SKILL.md +182 -0
- package/agent-template/.claude/skills/cli-extension/SKILL.md +108 -0
- package/agent-template/.claude/skills/core-extend/SKILL.md +99 -0
- package/agent-template/.claude/skills/database-adapter/SKILL.md +198 -0
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +161 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +171 -0
- package/agent-template/.claude/skills/module-update/SKILL.md +91 -0
- package/agent-template/.claude/skills/perf-audit/SKILL.md +98 -0
- package/agent-template/.claude/skills/release-eject-pr/SKILL.md +107 -0
- package/agent-template/.claude/skills/spec-approval/SKILL.md +106 -0
- package/agent-template/.claude/skills/test-hardening/SKILL.md +79 -0
- package/agent-template/.claude/skills/translations-i18n/SKILL.md +78 -0
- package/agent-template/.claude/skills/ux-design/SKILL.md +92 -0
- package/agent-template/.claude/skills/variables/SKILL.md +156 -0
- package/agent-template/.claude/skills/workflow-development/SKILL.md +192 -0
- package/agent-template/AGENTS.md +77 -0
- package/agent-template/CLAUDE.md +77 -0
- package/agent-template/docs/adr/0001-development-reload.md +16 -0
- package/agent-template/docs/adr/0002-durable-agent-execution.md +21 -0
- package/agent-template/docs/adr/0003-module-settings.md +22 -0
- package/agent-template/docs/adr/0004-enterprise-access-and-audit.md +36 -0
- package/agent-template/docs/adr/0005-sandbox-runtime-and-coding-agents.md +81 -0
- package/agent-template/docs/adr/0006-agentic-workflows.md +1702 -0
- package/agent-template/docs/adr/0007-module-owned-agents.md +429 -0
- package/agent-template/docs/adr/0008-database-adapter-contract.md +90 -0
- package/agent-template/docs/agent-contract.md +45 -0
- package/agent-template/docs/configuration.md +122 -0
- package/agent-template/docs/database-adapters.md +346 -0
- package/agent-template/docs/design-system.md +217 -0
- package/agent-template/docs/modules.md +146 -0
- package/agent-template/platform/scripts/build.mjs +38 -0
- package/agent-template/rulesync.jsonc +11 -0
- package/dist/bin.js +40 -2
- package/package.json +3 -2
- package/template/default/.prettierignore +9 -0
- package/template/default/README.md +12 -0
- package/template/default/flowdular.json +3 -3
- package/template/default/modules/example/package.json +2 -2
- package/template/default/package.json +6 -2
- package/template/default/platform/octane.config.ts +17 -6
- package/template/default/platform/package.json +3 -2
- package/template/default/pnpm-workspace.yaml +1 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
DatabaseHandle,
|
|
4
|
+
DatabaseTransaction,
|
|
5
|
+
} from '@flowdular/sdk/database';
|
|
6
|
+
import {
|
|
7
|
+
appendRecordHistory,
|
|
8
|
+
queryRecordHistory,
|
|
9
|
+
runDatabaseMigrations,
|
|
10
|
+
} from '@flowdular/sdk/database';
|
|
11
|
+
import {
|
|
12
|
+
diffFields,
|
|
13
|
+
type Actor,
|
|
14
|
+
type HistoryPage,
|
|
15
|
+
type HistoryQuery,
|
|
16
|
+
type TrackedFields,
|
|
17
|
+
} from '@flowdular/sdk/kernel';
|
|
18
|
+
import type { CatalogItem } from '../domain/types.ts';
|
|
19
|
+
import { databaseMigrations } from './migration.ts';
|
|
20
|
+
import { DuplicateSkuError, type CatalogRepository } from './repository.ts';
|
|
21
|
+
import {
|
|
22
|
+
canonicalDigest,
|
|
23
|
+
TargetIdempotencyCorruptionError,
|
|
24
|
+
TargetIdempotencyConflictError,
|
|
25
|
+
type TargetIdempotencyEntry,
|
|
26
|
+
type TargetIdempotencyRequest,
|
|
27
|
+
} from './target-idempotency.ts';
|
|
28
|
+
|
|
29
|
+
const HISTORY_TABLE = 'catalog_items_history_v2';
|
|
30
|
+
|
|
31
|
+
const COLUMNS = `id, tenant_id, sku, name, kind, unit, base_price_minor,
|
|
32
|
+
currency, status, created_at`;
|
|
33
|
+
|
|
34
|
+
/* Queries stay explicit. Catalog data never passes through a SQL rewriter, and
|
|
35
|
+
values always use the adapter's parameter channel. */
|
|
36
|
+
const LIST = `SELECT ${COLUMNS} FROM catalog_items
|
|
37
|
+
WHERE tenant_id = $1 ORDER BY sku_normalized, id`;
|
|
38
|
+
|
|
39
|
+
const FIND = `SELECT ${COLUMNS} FROM catalog_items WHERE tenant_id = $1 AND id = $2`;
|
|
40
|
+
|
|
41
|
+
const INSERT = `INSERT INTO catalog_items
|
|
42
|
+
(id, tenant_id, sku, sku_normalized, name, kind, unit,
|
|
43
|
+
base_price_minor, currency, status, created_at)
|
|
44
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`;
|
|
45
|
+
|
|
46
|
+
const UPDATE = `UPDATE catalog_items
|
|
47
|
+
SET name = $1, kind = $2, unit = $3, base_price_minor = $4, currency = $5
|
|
48
|
+
WHERE tenant_id = $6 AND id = $7 RETURNING ${COLUMNS}`;
|
|
49
|
+
|
|
50
|
+
const SET_STATUS = `UPDATE catalog_items SET status = $1 WHERE tenant_id = $2 AND id = $3
|
|
51
|
+
RETURNING ${COLUMNS}`;
|
|
52
|
+
|
|
53
|
+
const DELETE = 'DELETE FROM catalog_items WHERE tenant_id = $1 AND id = $2';
|
|
54
|
+
|
|
55
|
+
const FIND_IDEMPOTENCY = `SELECT operation_id, input_digest, result_json, result_digest
|
|
56
|
+
FROM catalog_idempotency_ledger
|
|
57
|
+
WHERE tenant_id = $1 AND idempotency_key = $2`;
|
|
58
|
+
|
|
59
|
+
const RECORD_IDEMPOTENCY = `INSERT INTO catalog_idempotency_ledger
|
|
60
|
+
(id, tenant_id, idempotency_key, operation_id, input_digest,
|
|
61
|
+
outcome, result_json, result_digest, created_at)
|
|
62
|
+
VALUES ($1, $2, $3, $4, $5, 'succeeded', $6, $7, $8)`;
|
|
63
|
+
|
|
64
|
+
interface CatalogItemRow {
|
|
65
|
+
id: string;
|
|
66
|
+
tenant_id: string;
|
|
67
|
+
sku: string;
|
|
68
|
+
name: string;
|
|
69
|
+
kind: CatalogItem['kind'];
|
|
70
|
+
unit: string;
|
|
71
|
+
base_price_minor: number | bigint | string;
|
|
72
|
+
currency: string;
|
|
73
|
+
status: CatalogItem['status'];
|
|
74
|
+
created_at: number | bigint | string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface IdempotencyRow {
|
|
78
|
+
operation_id: string;
|
|
79
|
+
input_digest: string;
|
|
80
|
+
result_json: string;
|
|
81
|
+
result_digest: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* 23505 is the SQLSTATE for a unique violation; the constraint name keeps a
|
|
85
|
+
different unique index on the table from being mistaken for the SKU. The
|
|
86
|
+
driver text is never surfaced, only the stable domain error. */
|
|
87
|
+
function isDuplicateSku(error: unknown): boolean {
|
|
88
|
+
const cause = error as { code?: unknown; constraint?: unknown };
|
|
89
|
+
const text = String(error);
|
|
90
|
+
return (
|
|
91
|
+
(cause?.code === '23505' || text.includes('23505')) &&
|
|
92
|
+
(String(cause?.constraint ?? '').includes('sku_normalized') ||
|
|
93
|
+
text.includes('sku_normalized'))
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* PostgreSQL returns BIGINT as a string, so every integer read crosses this
|
|
98
|
+
instead of trusting the driver's representation. */
|
|
99
|
+
function integer(value: number | bigint | string, field: string): number {
|
|
100
|
+
const normalized = Number(value);
|
|
101
|
+
if (!Number.isSafeInteger(normalized)) {
|
|
102
|
+
throw new Error(`The catalog database returned an invalid ${field}.`);
|
|
103
|
+
}
|
|
104
|
+
return normalized;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fromRow(row: CatalogItemRow): CatalogItem {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id,
|
|
110
|
+
tenantId: row.tenant_id,
|
|
111
|
+
sku: row.sku,
|
|
112
|
+
name: row.name,
|
|
113
|
+
kind: row.kind,
|
|
114
|
+
unit: row.unit,
|
|
115
|
+
basePriceMinor: integer(row.base_price_minor, 'price'),
|
|
116
|
+
currency: row.currency,
|
|
117
|
+
status: row.status,
|
|
118
|
+
createdAt: integer(row.created_at, 'timestamp'),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The fields a history version reports on. Identity, tenancy and creation time
|
|
123
|
+
cannot change and are never part of a diff. */
|
|
124
|
+
function tracked(item: CatalogItem): TrackedFields {
|
|
125
|
+
return {
|
|
126
|
+
sku: item.sku,
|
|
127
|
+
name: item.name,
|
|
128
|
+
kind: item.kind,
|
|
129
|
+
unit: item.unit,
|
|
130
|
+
basePriceMinor: item.basePriceMinor,
|
|
131
|
+
currency: item.currency,
|
|
132
|
+
status: item.status,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function migrateCatalogDatabase(
|
|
137
|
+
database: DatabaseHandle,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
await runDatabaseMigrations(database, 'catalog.core', databaseMigrations);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A repository over a platform-owned PostgreSQL handle. */
|
|
143
|
+
export class DatabaseCatalogRepository implements CatalogRepository {
|
|
144
|
+
constructor(
|
|
145
|
+
private readonly database: DatabaseHandle,
|
|
146
|
+
private readonly readyPromise: Promise<void> = Promise.resolve(),
|
|
147
|
+
) {}
|
|
148
|
+
|
|
149
|
+
async list(tenantId: string): Promise<readonly CatalogItem[]> {
|
|
150
|
+
await this.readyPromise;
|
|
151
|
+
const result = await this.database.transaction(
|
|
152
|
+
(transaction) =>
|
|
153
|
+
transaction.query<CatalogItemRow>({
|
|
154
|
+
text: LIST,
|
|
155
|
+
parameters: [tenantId],
|
|
156
|
+
}),
|
|
157
|
+
{ access: 'read', tenantId },
|
|
158
|
+
);
|
|
159
|
+
return result.rows.map(fromRow);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async find(tenantId: string, id: string): Promise<CatalogItem | null> {
|
|
163
|
+
await this.readyPromise;
|
|
164
|
+
const result = await this.database.transaction(
|
|
165
|
+
(transaction) =>
|
|
166
|
+
transaction.query<CatalogItemRow>({
|
|
167
|
+
text: FIND,
|
|
168
|
+
parameters: [tenantId, id],
|
|
169
|
+
}),
|
|
170
|
+
{ access: 'read', tenantId },
|
|
171
|
+
);
|
|
172
|
+
const row = result.rows[0];
|
|
173
|
+
return row ? fromRow(row) : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async create(
|
|
177
|
+
item: CatalogItem,
|
|
178
|
+
normalizedSku: string,
|
|
179
|
+
actor: Actor,
|
|
180
|
+
): Promise<CatalogItem> {
|
|
181
|
+
await this.readyPromise;
|
|
182
|
+
try {
|
|
183
|
+
return await this.database.transaction(
|
|
184
|
+
(transaction) => this.#insert(transaction, item, normalizedSku, actor),
|
|
185
|
+
{ access: 'write', tenantId: item.tenantId },
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (isDuplicateSku(error)) throw new DuplicateSkuError();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async createIdempotent(
|
|
194
|
+
item: CatalogItem,
|
|
195
|
+
normalizedSku: string,
|
|
196
|
+
actor: Actor,
|
|
197
|
+
idempotency: TargetIdempotencyRequest,
|
|
198
|
+
): Promise<CatalogItem> {
|
|
199
|
+
await this.readyPromise;
|
|
200
|
+
try {
|
|
201
|
+
return await this.database.transaction(
|
|
202
|
+
async (transaction) => {
|
|
203
|
+
const existing = await this.#idempotencyEntry(
|
|
204
|
+
transaction,
|
|
205
|
+
item.tenantId,
|
|
206
|
+
idempotency.key,
|
|
207
|
+
);
|
|
208
|
+
if (existing) {
|
|
209
|
+
if (
|
|
210
|
+
existing.operationId !== idempotency.operationId ||
|
|
211
|
+
existing.inputDigest !== idempotency.inputDigest
|
|
212
|
+
) {
|
|
213
|
+
throw new TargetIdempotencyConflictError();
|
|
214
|
+
}
|
|
215
|
+
return this.#result(existing, item.tenantId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const created = await this.#insert(
|
|
219
|
+
transaction,
|
|
220
|
+
item,
|
|
221
|
+
normalizedSku,
|
|
222
|
+
actor,
|
|
223
|
+
);
|
|
224
|
+
await transaction.execute({
|
|
225
|
+
text: RECORD_IDEMPOTENCY,
|
|
226
|
+
parameters: [
|
|
227
|
+
randomUUID(),
|
|
228
|
+
item.tenantId,
|
|
229
|
+
idempotency.key,
|
|
230
|
+
idempotency.operationId,
|
|
231
|
+
idempotency.inputDigest,
|
|
232
|
+
JSON.stringify(created),
|
|
233
|
+
canonicalDigest(created),
|
|
234
|
+
Date.now(),
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
return created;
|
|
238
|
+
},
|
|
239
|
+
{ access: 'write', tenantId: item.tenantId },
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (isDuplicateSku(error)) throw new DuplicateSkuError();
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async update(item: CatalogItem, actor: Actor): Promise<CatalogItem | null> {
|
|
248
|
+
await this.readyPromise;
|
|
249
|
+
return this.database.transaction(
|
|
250
|
+
async (transaction) => {
|
|
251
|
+
const before = await this.#find(transaction, item.tenantId, item.id);
|
|
252
|
+
if (!before) return null;
|
|
253
|
+
const result = await transaction.query<CatalogItemRow>({
|
|
254
|
+
text: UPDATE,
|
|
255
|
+
parameters: [
|
|
256
|
+
item.name,
|
|
257
|
+
item.kind,
|
|
258
|
+
item.unit,
|
|
259
|
+
item.basePriceMinor,
|
|
260
|
+
item.currency,
|
|
261
|
+
item.tenantId,
|
|
262
|
+
item.id,
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
const row = result.rows[0];
|
|
266
|
+
if (!row) return null;
|
|
267
|
+
const after = fromRow(row);
|
|
268
|
+
const changes = diffFields(tracked(before), tracked(after));
|
|
269
|
+
if (Object.keys(changes).length > 0) {
|
|
270
|
+
await appendRecordHistory(transaction, HISTORY_TABLE, {
|
|
271
|
+
tenantId: item.tenantId,
|
|
272
|
+
recordId: after.id,
|
|
273
|
+
action: 'updated',
|
|
274
|
+
actor,
|
|
275
|
+
changes,
|
|
276
|
+
occurredAt: Date.now(),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return after;
|
|
280
|
+
},
|
|
281
|
+
{ access: 'write', tenantId: item.tenantId },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async setStatus(
|
|
286
|
+
tenantId: string,
|
|
287
|
+
id: string,
|
|
288
|
+
status: CatalogItem['status'],
|
|
289
|
+
actor: Actor,
|
|
290
|
+
): Promise<CatalogItem | null> {
|
|
291
|
+
await this.readyPromise;
|
|
292
|
+
return this.database.transaction(
|
|
293
|
+
async (transaction) => {
|
|
294
|
+
const before = await this.#find(transaction, tenantId, id);
|
|
295
|
+
if (!before) return null;
|
|
296
|
+
const result = await transaction.query<CatalogItemRow>({
|
|
297
|
+
text: SET_STATUS,
|
|
298
|
+
parameters: [status, tenantId, id],
|
|
299
|
+
});
|
|
300
|
+
const row = result.rows[0];
|
|
301
|
+
if (!row) return null;
|
|
302
|
+
const after = fromRow(row);
|
|
303
|
+
if (before.status !== after.status) {
|
|
304
|
+
await appendRecordHistory(transaction, HISTORY_TABLE, {
|
|
305
|
+
tenantId,
|
|
306
|
+
recordId: id,
|
|
307
|
+
action: status === 'archived' ? 'archived' : 'restored',
|
|
308
|
+
actor,
|
|
309
|
+
changes: diffFields(tracked(before), tracked(after)),
|
|
310
|
+
occurredAt: Date.now(),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return after;
|
|
314
|
+
},
|
|
315
|
+
{ access: 'write', tenantId },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async delete(tenantId: string, id: string, actor: Actor): Promise<boolean> {
|
|
320
|
+
await this.readyPromise;
|
|
321
|
+
return this.database.transaction(
|
|
322
|
+
async (transaction) => {
|
|
323
|
+
const before = await this.#find(transaction, tenantId, id);
|
|
324
|
+
if (!before) return false;
|
|
325
|
+
await appendRecordHistory(transaction, HISTORY_TABLE, {
|
|
326
|
+
tenantId,
|
|
327
|
+
recordId: id,
|
|
328
|
+
action: 'deleted',
|
|
329
|
+
actor,
|
|
330
|
+
changes: diffFields(tracked(before), {
|
|
331
|
+
sku: null,
|
|
332
|
+
name: null,
|
|
333
|
+
kind: null,
|
|
334
|
+
unit: null,
|
|
335
|
+
basePriceMinor: null,
|
|
336
|
+
currency: null,
|
|
337
|
+
status: null,
|
|
338
|
+
}),
|
|
339
|
+
occurredAt: Date.now(),
|
|
340
|
+
});
|
|
341
|
+
const result = await transaction.execute({
|
|
342
|
+
text: DELETE,
|
|
343
|
+
parameters: [tenantId, id],
|
|
344
|
+
});
|
|
345
|
+
return result.affectedRows === 1;
|
|
346
|
+
},
|
|
347
|
+
{ access: 'write', tenantId },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async history(query: HistoryQuery): Promise<HistoryPage> {
|
|
352
|
+
await this.readyPromise;
|
|
353
|
+
return this.database.transaction(
|
|
354
|
+
(transaction) => queryRecordHistory(transaction, HISTORY_TABLE, query),
|
|
355
|
+
{ access: 'read', tenantId: query.tenantId },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async #find(
|
|
360
|
+
transaction: DatabaseTransaction,
|
|
361
|
+
tenantId: string,
|
|
362
|
+
id: string,
|
|
363
|
+
): Promise<CatalogItem | null> {
|
|
364
|
+
const result = await transaction.query<CatalogItemRow>({
|
|
365
|
+
text: FIND,
|
|
366
|
+
parameters: [tenantId, id],
|
|
367
|
+
});
|
|
368
|
+
const row = result.rows[0];
|
|
369
|
+
return row ? fromRow(row) : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async #insert(
|
|
373
|
+
transaction: DatabaseTransaction,
|
|
374
|
+
item: CatalogItem,
|
|
375
|
+
normalizedSku: string,
|
|
376
|
+
actor: Actor,
|
|
377
|
+
): Promise<CatalogItem> {
|
|
378
|
+
await transaction.execute({
|
|
379
|
+
text: INSERT,
|
|
380
|
+
parameters: [
|
|
381
|
+
item.id,
|
|
382
|
+
item.tenantId,
|
|
383
|
+
item.sku,
|
|
384
|
+
normalizedSku,
|
|
385
|
+
item.name,
|
|
386
|
+
item.kind,
|
|
387
|
+
item.unit,
|
|
388
|
+
item.basePriceMinor,
|
|
389
|
+
item.currency,
|
|
390
|
+
item.status,
|
|
391
|
+
item.createdAt,
|
|
392
|
+
],
|
|
393
|
+
});
|
|
394
|
+
await appendRecordHistory(transaction, HISTORY_TABLE, {
|
|
395
|
+
tenantId: item.tenantId,
|
|
396
|
+
recordId: item.id,
|
|
397
|
+
action: 'created',
|
|
398
|
+
actor,
|
|
399
|
+
changes: diffFields(null, tracked(item)),
|
|
400
|
+
occurredAt: item.createdAt,
|
|
401
|
+
});
|
|
402
|
+
return item;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async #idempotencyEntry(
|
|
406
|
+
transaction: DatabaseTransaction,
|
|
407
|
+
tenantId: string,
|
|
408
|
+
key: string,
|
|
409
|
+
): Promise<TargetIdempotencyEntry | null> {
|
|
410
|
+
const result = await transaction.query<IdempotencyRow>({
|
|
411
|
+
text: FIND_IDEMPOTENCY,
|
|
412
|
+
parameters: [tenantId, key],
|
|
413
|
+
});
|
|
414
|
+
const row = result.rows[0];
|
|
415
|
+
return row
|
|
416
|
+
? {
|
|
417
|
+
operationId: row.operation_id,
|
|
418
|
+
inputDigest: row.input_digest,
|
|
419
|
+
resultJson: row.result_json,
|
|
420
|
+
resultDigest: row.result_digest,
|
|
421
|
+
}
|
|
422
|
+
: null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#result(entry: TargetIdempotencyEntry, tenantId: string): CatalogItem {
|
|
426
|
+
let result: CatalogItem;
|
|
427
|
+
try {
|
|
428
|
+
result = JSON.parse(entry.resultJson) as CatalogItem;
|
|
429
|
+
} catch {
|
|
430
|
+
throw new TargetIdempotencyCorruptionError();
|
|
431
|
+
}
|
|
432
|
+
if (
|
|
433
|
+
result.tenantId !== tenantId ||
|
|
434
|
+
canonicalDigest(result) !== entry.resultDigest
|
|
435
|
+
) {
|
|
436
|
+
throw new TargetIdempotencyCorruptionError();
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { DatabaseMigration } from '@flowdular/sdk/database';
|
|
2
|
+
import { postgresTenantTableState } from '@flowdular/sdk/database';
|
|
3
|
+
|
|
4
|
+
/* Every constant mirrors its migrations/<id>.up.sql file byte for byte;
|
|
5
|
+
tests/migrations.test.ts fails on drift. */
|
|
6
|
+
|
|
7
|
+
export const CATALOG_MIGRATION_001 = `CREATE TABLE IF NOT EXISTS catalog_items (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
tenant_id TEXT NOT NULL,
|
|
10
|
+
sku TEXT NOT NULL,
|
|
11
|
+
sku_normalized TEXT NOT NULL,
|
|
12
|
+
name TEXT NOT NULL,
|
|
13
|
+
kind TEXT NOT NULL CHECK (kind IN ('product', 'service')),
|
|
14
|
+
unit TEXT NOT NULL,
|
|
15
|
+
base_price_minor BIGINT NOT NULL CHECK (base_price_minor >= 0),
|
|
16
|
+
currency TEXT NOT NULL,
|
|
17
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
|
|
18
|
+
created_at BIGINT NOT NULL,
|
|
19
|
+
UNIQUE (tenant_id, sku_normalized)
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS catalog_items_tenant_sku_idx
|
|
22
|
+
ON catalog_items (tenant_id, sku_normalized, id);
|
|
23
|
+
ALTER TABLE catalog_items ENABLE ROW LEVEL SECURITY;
|
|
24
|
+
ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
|
|
25
|
+
CREATE POLICY catalog_items_tenant_policy ON catalog_items
|
|
26
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
27
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export const CATALOG_MIGRATION_002_HISTORY = `CREATE TABLE IF NOT EXISTS catalog_items_history (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
tenant_id TEXT NOT NULL,
|
|
33
|
+
record_id TEXT NOT NULL,
|
|
34
|
+
version BIGINT NOT NULL CHECK (version >= 1),
|
|
35
|
+
action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 32),
|
|
36
|
+
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'agent')),
|
|
37
|
+
actor_id TEXT NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 128),
|
|
38
|
+
actor_label TEXT NOT NULL CHECK (length(actor_label) BETWEEN 1 AND 160),
|
|
39
|
+
run_id TEXT CHECK (run_id IS NULL OR length(run_id) BETWEEN 1 AND 128),
|
|
40
|
+
changes_json TEXT NOT NULL,
|
|
41
|
+
occurred_at BIGINT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS catalog_items_history_tenant_record_version_idx
|
|
44
|
+
ON catalog_items_history (tenant_id, record_id, version DESC);
|
|
45
|
+
ALTER TABLE catalog_items_history ENABLE ROW LEVEL SECURITY;
|
|
46
|
+
ALTER TABLE catalog_items_history FORCE ROW LEVEL SECURITY;
|
|
47
|
+
CREATE POLICY catalog_items_history_tenant_policy ON catalog_items_history
|
|
48
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
49
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
export const CATALOG_MIGRATION_003_HISTORY_SERVICE_ACTORS = `CREATE TABLE IF NOT EXISTS catalog_items_history_v2 (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
tenant_id TEXT NOT NULL,
|
|
55
|
+
record_id TEXT NOT NULL,
|
|
56
|
+
version BIGINT NOT NULL CHECK (version >= 1),
|
|
57
|
+
action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 32),
|
|
58
|
+
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'agent', 'service')),
|
|
59
|
+
actor_id TEXT NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 128),
|
|
60
|
+
actor_label TEXT NOT NULL CHECK (length(actor_label) BETWEEN 1 AND 160),
|
|
61
|
+
run_id TEXT CHECK (run_id IS NULL OR length(run_id) BETWEEN 1 AND 128),
|
|
62
|
+
configured_by_json TEXT CHECK (configured_by_json IS NULL OR configured_by_json::jsonb IS NOT NULL),
|
|
63
|
+
changes_json TEXT NOT NULL,
|
|
64
|
+
occurred_at BIGINT NOT NULL,
|
|
65
|
+
CHECK (
|
|
66
|
+
(actor_kind = 'agent' AND run_id IS NOT NULL)
|
|
67
|
+
OR (actor_kind IN ('user', 'service') AND run_id IS NULL)
|
|
68
|
+
),
|
|
69
|
+
CHECK (
|
|
70
|
+
(actor_kind = 'service' AND configured_by_json IS NOT NULL)
|
|
71
|
+
OR (actor_kind IN ('user', 'agent') AND configured_by_json IS NULL)
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
CREATE UNIQUE INDEX IF NOT EXISTS catalog_items_history_v2_tenant_record_version_idx
|
|
75
|
+
ON catalog_items_history_v2 (tenant_id, record_id, version DESC);
|
|
76
|
+
INSERT INTO catalog_items_history_v2
|
|
77
|
+
(id, tenant_id, record_id, version, action, actor_kind, actor_id,
|
|
78
|
+
actor_label, run_id, configured_by_json, changes_json, occurred_at)
|
|
79
|
+
SELECT id, tenant_id, record_id, version, action, actor_kind, actor_id,
|
|
80
|
+
actor_label, run_id, NULL, changes_json, occurred_at
|
|
81
|
+
FROM catalog_items_history
|
|
82
|
+
ON CONFLICT DO NOTHING;
|
|
83
|
+
ALTER TABLE catalog_items_history_v2 ENABLE ROW LEVEL SECURITY;
|
|
84
|
+
ALTER TABLE catalog_items_history_v2 FORCE ROW LEVEL SECURITY;
|
|
85
|
+
CREATE POLICY catalog_items_history_v2_tenant_policy ON catalog_items_history_v2
|
|
86
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
87
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export const CATALOG_MIGRATION_004_IDEMPOTENCY_LEDGER = `CREATE TABLE IF NOT EXISTS catalog_idempotency_ledger (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
tenant_id TEXT NOT NULL,
|
|
93
|
+
idempotency_key TEXT NOT NULL CHECK (length(idempotency_key) BETWEEN 8 AND 128),
|
|
94
|
+
operation_id TEXT NOT NULL CHECK (length(operation_id) BETWEEN 3 AND 160),
|
|
95
|
+
input_digest TEXT NOT NULL CHECK (length(input_digest) = 71),
|
|
96
|
+
outcome TEXT NOT NULL CHECK (outcome = 'succeeded'),
|
|
97
|
+
result_json TEXT NOT NULL CHECK (result_json::jsonb IS NOT NULL),
|
|
98
|
+
result_digest TEXT NOT NULL CHECK (length(result_digest) = 71),
|
|
99
|
+
created_at BIGINT NOT NULL,
|
|
100
|
+
UNIQUE (tenant_id, idempotency_key)
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS catalog_idempotency_ledger_tenant_operation_idx
|
|
103
|
+
ON catalog_idempotency_ledger (tenant_id, operation_id, created_at, id);
|
|
104
|
+
ALTER TABLE catalog_idempotency_ledger ENABLE ROW LEVEL SECURITY;
|
|
105
|
+
ALTER TABLE catalog_idempotency_ledger FORCE ROW LEVEL SECURITY;
|
|
106
|
+
CREATE POLICY catalog_idempotency_ledger_tenant_policy ON catalog_idempotency_ledger
|
|
107
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
108
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
112
|
+
{
|
|
113
|
+
id: '0001_catalog_core',
|
|
114
|
+
sql: { postgresql: CATALOG_MIGRATION_001 },
|
|
115
|
+
inspectExisting: (database) =>
|
|
116
|
+
postgresTenantTableState(
|
|
117
|
+
database,
|
|
118
|
+
'catalog_items',
|
|
119
|
+
'catalog_items_tenant_policy',
|
|
120
|
+
[() => database.schema.hasIndex('catalog_items_tenant_sku_idx')],
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: '0002_catalog_history',
|
|
125
|
+
sql: { postgresql: CATALOG_MIGRATION_002_HISTORY },
|
|
126
|
+
inspectExisting: (database) =>
|
|
127
|
+
postgresTenantTableState(
|
|
128
|
+
database,
|
|
129
|
+
'catalog_items_history',
|
|
130
|
+
'catalog_items_history_tenant_policy',
|
|
131
|
+
[
|
|
132
|
+
() =>
|
|
133
|
+
database.schema.hasIndex(
|
|
134
|
+
'catalog_items_history_tenant_record_version_idx',
|
|
135
|
+
),
|
|
136
|
+
],
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: '0003_catalog_history_service_actors',
|
|
141
|
+
sql: { postgresql: CATALOG_MIGRATION_003_HISTORY_SERVICE_ACTORS },
|
|
142
|
+
inspectExisting: (database) =>
|
|
143
|
+
postgresTenantTableState(
|
|
144
|
+
database,
|
|
145
|
+
'catalog_items_history_v2',
|
|
146
|
+
'catalog_items_history_v2_tenant_policy',
|
|
147
|
+
[
|
|
148
|
+
() =>
|
|
149
|
+
database.schema.hasIndex(
|
|
150
|
+
'catalog_items_history_v2_tenant_record_version_idx',
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: '0004_catalog_idempotency_ledger',
|
|
157
|
+
sql: { postgresql: CATALOG_MIGRATION_004_IDEMPOTENCY_LEDGER },
|
|
158
|
+
inspectExisting: (database) =>
|
|
159
|
+
postgresTenantTableState(
|
|
160
|
+
database,
|
|
161
|
+
'catalog_idempotency_ledger',
|
|
162
|
+
'catalog_idempotency_ledger_tenant_policy',
|
|
163
|
+
[
|
|
164
|
+
() =>
|
|
165
|
+
database.schema.hasIndex(
|
|
166
|
+
'catalog_idempotency_ledger_tenant_operation_idx',
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Actor, HistoryPage, HistoryQuery } from '@flowdular/sdk/kernel';
|
|
2
|
+
import type { CatalogItem } from '../domain/types.ts';
|
|
3
|
+
import type { TargetIdempotencyRequest } from './target-idempotency.ts';
|
|
4
|
+
|
|
5
|
+
export class DuplicateSkuError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('An item with this SKU already exists in the active tenant.');
|
|
8
|
+
this.name = 'DuplicateSkuError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The database-agnostic business port. No driver type crosses it. */
|
|
13
|
+
export interface CatalogRepository {
|
|
14
|
+
list(tenantId: string): Promise<readonly CatalogItem[]>;
|
|
15
|
+
find(tenantId: string, id: string): Promise<CatalogItem | null>;
|
|
16
|
+
create(
|
|
17
|
+
item: CatalogItem,
|
|
18
|
+
normalizedSku: string,
|
|
19
|
+
actor: Actor,
|
|
20
|
+
): Promise<CatalogItem>;
|
|
21
|
+
createIdempotent(
|
|
22
|
+
item: CatalogItem,
|
|
23
|
+
normalizedSku: string,
|
|
24
|
+
actor: Actor,
|
|
25
|
+
idempotency: TargetIdempotencyRequest,
|
|
26
|
+
): Promise<CatalogItem>;
|
|
27
|
+
update(item: CatalogItem, actor: Actor): Promise<CatalogItem | null>;
|
|
28
|
+
setStatus(
|
|
29
|
+
tenantId: string,
|
|
30
|
+
id: string,
|
|
31
|
+
status: CatalogItem['status'],
|
|
32
|
+
actor: Actor,
|
|
33
|
+
): Promise<CatalogItem | null>;
|
|
34
|
+
delete(tenantId: string, id: string, actor: Actor): Promise<boolean>;
|
|
35
|
+
history(query: HistoryQuery): Promise<HistoryPage>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface TargetIdempotencyRequest {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly operationId: string;
|
|
6
|
+
readonly inputDigest: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TargetIdempotencyEntry {
|
|
10
|
+
readonly operationId: string;
|
|
11
|
+
readonly inputDigest: string;
|
|
12
|
+
readonly resultJson: string;
|
|
13
|
+
readonly resultDigest: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TargetIdempotencyConflictError extends Error {
|
|
17
|
+
readonly code = 'CATALOG_IDEMPOTENCY_CONFLICT';
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
super(
|
|
21
|
+
'The idempotency key is bound to another catalog operation or input.',
|
|
22
|
+
);
|
|
23
|
+
this.name = 'TargetIdempotencyConflictError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TargetIdempotencyCorruptionError extends Error {
|
|
28
|
+
readonly code = 'CATALOG_IDEMPOTENCY_LEDGER_CORRUPT';
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super('The catalog idempotency ledger contains an invalid result.');
|
|
32
|
+
this.name = 'TargetIdempotencyCorruptionError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function canonical(value: unknown): string {
|
|
37
|
+
if (value === null || typeof value !== 'object') {
|
|
38
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Canonical idempotency input must contain finite numbers.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const encoded = JSON.stringify(value);
|
|
44
|
+
if (encoded === undefined) {
|
|
45
|
+
throw new Error('Canonical idempotency input must be JSON serializable.');
|
|
46
|
+
}
|
|
47
|
+
return encoded;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
50
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
51
|
+
.filter(([, entry]) => entry !== undefined)
|
|
52
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
53
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
|
|
54
|
+
.join(',')}}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function canonicalDigest(value: unknown): string {
|
|
58
|
+
return `sha256:${createHash('sha256').update(canonical(value)).digest('hex')}`;
|
|
59
|
+
}
|