@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.
- package/.ai/platform-capabilities.md +4 -2
- package/.ai/policies/capabilities.yaml +30 -3
- 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/.ai/skills/cli-extension/SKILL.md +1 -1
- package/.ai/skills/deploy-operate/SKILL.md +7 -2
- 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/migrations/0005_approvals_grant_audit.down.sql +4 -0
- package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
- package/modules/approvals/module.json +2 -2
- package/modules/approvals/package.json +1 -1
- package/modules/approvals/spec/module.yaml +12 -3
- package/modules/approvals/src/domain/capability.ts +12 -0
- package/modules/approvals/src/domain/grant.ts +69 -0
- package/modules/approvals/src/domain/types.ts +14 -0
- package/modules/approvals/src/index.ts +9 -0
- package/modules/approvals/src/platform.ts +8 -0
- package/modules/approvals/src/server/runtime.ts +4 -0
- package/modules/approvals/src/services/approvals-service.ts +80 -0
- package/modules/approvals/src/services/database-repository.ts +65 -6
- package/modules/approvals/src/services/migration.ts +34 -0
- package/modules/approvals/src/services/repository.ts +8 -0
- 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/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
- package/modules/connectors/module.json +8 -4
- package/modules/connectors/package.json +2 -1
- package/modules/connectors/spec/module.yaml +3 -2
- package/modules/connectors/src/cli/commands.json +17 -0
- package/modules/connectors/src/cli/index.ts +126 -0
- package/modules/connectors/src/services/credential-rotation.ts +221 -0
- package/modules/connectors/src/services/credential-vault.ts +6 -0
- package/modules/connectors/src/services/migration.ts +36 -0
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
- package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
- package/modules/documents/module.json +8 -4
- package/modules/documents/package.json +2 -1
- package/modules/documents/spec/module.yaml +3 -2
- package/modules/documents/src/cli/commands.json +17 -0
- package/modules/documents/src/cli/index.ts +145 -0
- package/modules/documents/src/services/database-repository.ts +15 -4
- package/modules/documents/src/services/documents-service.ts +13 -9
- package/modules/documents/src/services/migration.ts +35 -0
- package/modules/documents/src/services/repository.ts +12 -2
- package/modules/documents/src/services/storage-rotation.ts +157 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
- package/modules/exports/module.json +8 -4
- package/modules/exports/package.json +2 -1
- package/modules/exports/spec/module.yaml +3 -2
- package/modules/exports/src/cli/commands.json +17 -0
- package/modules/exports/src/cli/index.ts +145 -0
- package/modules/exports/src/server/index.ts +0 -1
- package/modules/exports/src/services/data-classes.ts +16 -13
- package/modules/exports/src/services/database-repository.ts +30 -32
- package/modules/exports/src/services/migration.ts +27 -0
- package/modules/exports/src/services/repository.ts +9 -10
- package/modules/exports/src/services/storage-rotation.ts +138 -0
- 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/database/src/backup.ts +1 -0
- package/packages/database/src/migrations.ts +7 -0
- package/packages/harness/src/runtime.ts +169 -10
- package/packages/harness/src/tool-adapters.ts +6 -13
- package/packages/kernel/src/approval-grant.ts +310 -0
- package/packages/kernel/src/index.ts +20 -0
- 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/packages/storage/src/envelope.ts +70 -21
- package/packages/storage/src/index.ts +7 -1
- package/packages/storage/src/port.ts +12 -1
- package/packages/storage/src/reseal.ts +128 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineCliExtension,
|
|
3
|
+
type CliExtensionContext,
|
|
4
|
+
} from '@flowdular/sdk/cli-protocol';
|
|
5
|
+
import {
|
|
6
|
+
DATABASE_CAPABILITY_IDS,
|
|
7
|
+
DATABASE_DIALECT_IDS,
|
|
8
|
+
type DatabaseAdapterLease,
|
|
9
|
+
} from '@flowdular/sdk/database';
|
|
10
|
+
import {
|
|
11
|
+
createStorageKeyring,
|
|
12
|
+
createStoragePort,
|
|
13
|
+
storageConfigFromEnvironment,
|
|
14
|
+
} from '@flowdular/sdk/storage';
|
|
15
|
+
import { migrateExportsDatabase } from '../services/database-repository.ts';
|
|
16
|
+
import {
|
|
17
|
+
rotateExportObjects,
|
|
18
|
+
type StorageRotationReport,
|
|
19
|
+
} from '../services/storage-rotation.ts';
|
|
20
|
+
|
|
21
|
+
const rotateCapability = {
|
|
22
|
+
id: 'exports.storage.rotate',
|
|
23
|
+
version: 1,
|
|
24
|
+
summary:
|
|
25
|
+
'Re-seal stored export files with the current storage encryption key.',
|
|
26
|
+
risk: 'process' as const,
|
|
27
|
+
requiresApprovedSpec: false,
|
|
28
|
+
supportsDryRun: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface OpenDatabase {
|
|
32
|
+
readonly leases: readonly DatabaseAdapterLease[];
|
|
33
|
+
readonly runtime: DatabaseAdapterLease;
|
|
34
|
+
readonly background: DatabaseAdapterLease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The operator command reads the same deployment database the platform does;
|
|
38
|
+
the runner owns the provider and a module owns no driver, so it arrives on
|
|
39
|
+
the context. It may be the first thing to touch a fresh database, so it
|
|
40
|
+
migrates before reading. */
|
|
41
|
+
async function open(context: CliExtensionContext): Promise<OpenDatabase> {
|
|
42
|
+
const databases = context.databases;
|
|
43
|
+
if (!databases) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'exports.core CLI commands read the deployment database, and this workspace has none configured.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const requirements = {
|
|
49
|
+
dialectIds: [DATABASE_DIALECT_IDS.postgresql],
|
|
50
|
+
capabilities: [DATABASE_CAPABILITY_IDS.TRANSACTIONS],
|
|
51
|
+
};
|
|
52
|
+
const migration = await databases.acquire({
|
|
53
|
+
namespace: 'exports.core',
|
|
54
|
+
purpose: 'migration',
|
|
55
|
+
requirements,
|
|
56
|
+
});
|
|
57
|
+
await migrateExportsDatabase(migration.database);
|
|
58
|
+
const runtime = await databases.acquire({
|
|
59
|
+
namespace: 'exports.core',
|
|
60
|
+
purpose: 'runtime',
|
|
61
|
+
requirements,
|
|
62
|
+
});
|
|
63
|
+
/* The inventory lists workspaces across the whole deployment, which only
|
|
64
|
+
the cross-tenant read-only role may do. */
|
|
65
|
+
const background = await databases.acquire({
|
|
66
|
+
namespace: 'exports.core',
|
|
67
|
+
purpose: 'background',
|
|
68
|
+
requirements,
|
|
69
|
+
});
|
|
70
|
+
return { leases: [migration, runtime, background], runtime, background };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* The provider belongs to the runner; only the leases this command took are
|
|
74
|
+
released here. */
|
|
75
|
+
async function close(open: OpenDatabase): Promise<void> {
|
|
76
|
+
for (const lease of open.leases) await lease.release();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function storageRotationWarnings(
|
|
80
|
+
report: StorageRotationReport,
|
|
81
|
+
variable: string,
|
|
82
|
+
): string[] {
|
|
83
|
+
const warnings: string[] = [];
|
|
84
|
+
if (report.unknown > 0) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
`${report.unknown} objects are sealed under a key this ring does not hold and were left as they are. Put that key back in ${variable}_PREVIOUS before retiring it.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (report.refused > 0) {
|
|
90
|
+
warnings.push(
|
|
91
|
+
`${report.refused} objects failed authentication under the key they name and were left as they are. Restore them from the object store backup.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (report.missing > 0) {
|
|
95
|
+
warnings.push(
|
|
96
|
+
`${report.missing} jobs name a file the store no longer holds.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return warnings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const cliExtension = defineCliExtension({
|
|
103
|
+
protocolVersion: 1,
|
|
104
|
+
moduleId: 'exports.core',
|
|
105
|
+
commands: [
|
|
106
|
+
{
|
|
107
|
+
path: ['exports', 'secrets-rotate'],
|
|
108
|
+
capability: rotateCapability,
|
|
109
|
+
execute: async (context) => {
|
|
110
|
+
/* The report names key ids and counts only; no file content reaches
|
|
111
|
+
the command output. The port is the platform's own, built from the
|
|
112
|
+
same environment the server reads. */
|
|
113
|
+
const storage = createStoragePort(
|
|
114
|
+
storageConfigFromEnvironment(process.env, context.workspaceRoot),
|
|
115
|
+
{ keyring: createStorageKeyring(process.env, context.workspaceRoot) },
|
|
116
|
+
);
|
|
117
|
+
const opened = await open(context);
|
|
118
|
+
try {
|
|
119
|
+
const report = await rotateExportObjects({
|
|
120
|
+
runtime: opened.runtime.database,
|
|
121
|
+
background: opened.background.database,
|
|
122
|
+
storage,
|
|
123
|
+
apply: context.apply,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
data: { moduleId: 'exports.core', ...report },
|
|
127
|
+
evidence: [
|
|
128
|
+
'modules/exports/spec/module.yaml',
|
|
129
|
+
'docs/operations.md',
|
|
130
|
+
],
|
|
131
|
+
warnings: storageRotationWarnings(
|
|
132
|
+
report,
|
|
133
|
+
'FD_STORAGE_ENCRYPTION_KEY',
|
|
134
|
+
),
|
|
135
|
+
};
|
|
136
|
+
} finally {
|
|
137
|
+
await close(opened);
|
|
138
|
+
await storage.dispose();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export default cliExtension;
|
|
@@ -33,19 +33,22 @@ export function exportsDataClass(
|
|
|
33
33
|
exportable: true,
|
|
34
34
|
sweep: async ({ tenantId, cutoff, limit }) => {
|
|
35
35
|
const open = await repository();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
/* The file goes before the row it belongs to, under the row lock. An
|
|
37
|
+
interrupted pass then leaves a row naming a file that is gone, which
|
|
38
|
+
the next pass removes and a read answers as gone; the other order
|
|
39
|
+
would leave a file nothing references and no pass could ever find. */
|
|
40
|
+
const removed = await open.sweepJobs(
|
|
41
|
+
tenantId,
|
|
42
|
+
{
|
|
43
|
+
settledBefore: cutoff.getTime(),
|
|
44
|
+
/* The platform's number is the caller's; this is the module's own
|
|
45
|
+
bound on one pass. */
|
|
46
|
+
limit: Math.min(Math.max(Math.trunc(limit), 1), EXPORT_SWEEP_JOBS),
|
|
47
|
+
},
|
|
48
|
+
async (objectIds) =>
|
|
49
|
+
(await service()).discardObjects(tenantId, objectIds),
|
|
50
|
+
);
|
|
51
|
+
return { removed };
|
|
49
52
|
},
|
|
50
53
|
/* Metadata only. The requester snapshot is bookkeeping for a background
|
|
51
54
|
stage, and the exported files are the lists' own data, already covered
|
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
ExportJobPage,
|
|
18
18
|
ExportJobQuery,
|
|
19
19
|
ExportRepository,
|
|
20
|
-
ExportSweepBatch,
|
|
21
20
|
ExportSweepInput,
|
|
22
21
|
SettleExportJobInput,
|
|
23
22
|
} from './repository.ts';
|
|
@@ -67,13 +66,14 @@ const SQL = {
|
|
|
67
66
|
WHERE tenant_id = $1 AND id = $2 AND status = 'running'
|
|
68
67
|
RETURNING ${JOB_COLUMNS}`,
|
|
69
68
|
|
|
70
|
-
/* The batch is chosen once and
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
/* The batch is chosen once and locked: the files and then the rows go from
|
|
70
|
+
one chosen set, and a rewrite of a file under the same row lock waits for
|
|
71
|
+
the delete or is waited for by it. */
|
|
73
72
|
sweepBatch: `SELECT id, object_id FROM exports_jobs
|
|
74
73
|
WHERE tenant_id = $1 AND started_at < $2
|
|
75
74
|
AND status IN ('completed', 'failed')
|
|
76
|
-
ORDER BY started_at, id LIMIT $3
|
|
75
|
+
ORDER BY started_at, id LIMIT $3
|
|
76
|
+
FOR UPDATE`,
|
|
77
77
|
|
|
78
78
|
exportPage: `SELECT ${JOB_COLUMNS} FROM exports_jobs
|
|
79
79
|
WHERE tenant_id = $1 AND (started_at, id) > ($2, $3)
|
|
@@ -319,40 +319,38 @@ export class DatabaseExportRepository implements ExportRepository {
|
|
|
319
319
|
return row ? jobFromRow(row) : null;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
async
|
|
322
|
+
async sweepJobs(
|
|
323
323
|
tenantId: string,
|
|
324
324
|
input: ExportSweepInput,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
discard: (objectIds: readonly string[]) => Promise<void>,
|
|
326
|
+
): Promise<number> {
|
|
327
|
+
return this.#handles.runtime.transaction(
|
|
328
|
+
async (transaction) => {
|
|
329
|
+
const chosen = await transaction.query<{
|
|
330
|
+
id: string;
|
|
331
|
+
object_id: string | null;
|
|
332
|
+
}>({
|
|
329
333
|
text: SQL.sweepBatch,
|
|
330
334
|
parameters: [tenantId, input.settledBefore, input.limit],
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
async deleteJobs(tenantId: string, ids: readonly string[]): Promise<number> {
|
|
344
|
-
if (ids.length === 0) return 0;
|
|
345
|
-
const parameters: (string | number)[] = [tenantId, ...ids];
|
|
346
|
-
const placeholders = ids.map((_id, index) => `$${index + 2}`).join(', ');
|
|
347
|
-
const result = await this.#handles.runtime.transaction(
|
|
348
|
-
(transaction) =>
|
|
349
|
-
transaction.execute({
|
|
335
|
+
});
|
|
336
|
+
if (chosen.rows.length === 0) return 0;
|
|
337
|
+
await discard(
|
|
338
|
+
chosen.rows.flatMap((row) =>
|
|
339
|
+
row.object_id === null ? [] : [row.object_id],
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
const ids = chosen.rows.map((row) => row.id);
|
|
343
|
+
const placeholders = ids
|
|
344
|
+
.map((_id, index) => `$${index + 2}`)
|
|
345
|
+
.join(', ');
|
|
346
|
+
const result = await transaction.execute({
|
|
350
347
|
text: `DELETE FROM exports_jobs WHERE tenant_id = $1 AND id IN (${placeholders})`,
|
|
351
|
-
parameters,
|
|
352
|
-
})
|
|
348
|
+
parameters: [tenantId, ...ids],
|
|
349
|
+
});
|
|
350
|
+
return result.affectedRows;
|
|
351
|
+
},
|
|
353
352
|
{ access: 'write', tenantId },
|
|
354
353
|
);
|
|
355
|
-
return result.affectedRows;
|
|
356
354
|
}
|
|
357
355
|
|
|
358
356
|
async exportJobs(
|
|
@@ -57,6 +57,18 @@ REVOKE SELECT ON exports_jobs FROM coreloom_background;
|
|
|
57
57
|
GRANT SELECT (tenant_id, id, status, started_at) ON exports_jobs TO coreloom_background;
|
|
58
58
|
`;
|
|
59
59
|
|
|
60
|
+
/* Mirrors migrations/0003_exports_rotation_inventory.up.sql byte for byte. */
|
|
61
|
+
export const EXPORTS_MIGRATION_003_ROTATION_INVENTORY = `-- The storage key rotation has to find the workspaces that still hold export
|
|
62
|
+
-- files before it knows which files those are. The routing policy shows the
|
|
63
|
+
-- background role only waiting and running jobs; this one adds the completed
|
|
64
|
+
-- jobs under the same four routing columns, so the object id, the list, the
|
|
65
|
+
-- requester and every count stay invisible to it, and every file it names is
|
|
66
|
+
-- read again under the workspace that row named.
|
|
67
|
+
CREATE POLICY exports_jobs_rotation_policy ON exports_jobs
|
|
68
|
+
FOR SELECT TO coreloom_background
|
|
69
|
+
USING (status = 'completed');
|
|
70
|
+
`;
|
|
71
|
+
|
|
60
72
|
export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
61
73
|
{
|
|
62
74
|
id: '0001_exports_core',
|
|
@@ -86,4 +98,19 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
|
86
98
|
return result.rows[0]?.granted ? 'complete' : 'absent';
|
|
87
99
|
},
|
|
88
100
|
},
|
|
101
|
+
{
|
|
102
|
+
id: '0003_exports_rotation_inventory',
|
|
103
|
+
sql: { postgresql: EXPORTS_MIGRATION_003_ROTATION_INVENTORY },
|
|
104
|
+
/* A policy leaves no schema object behind, so its catalogue row is what
|
|
105
|
+
proves this migration ran. */
|
|
106
|
+
inspectExisting: async (database) => {
|
|
107
|
+
const result = await database.query<{ present: boolean }>({
|
|
108
|
+
text: `SELECT EXISTS (
|
|
109
|
+
SELECT 1 FROM pg_policies
|
|
110
|
+
WHERE tablename = 'exports_jobs' AND policyname = 'exports_jobs_rotation_policy'
|
|
111
|
+
) AS present`,
|
|
112
|
+
});
|
|
113
|
+
return result.rows[0]?.present === true ? 'complete' : 'absent';
|
|
114
|
+
},
|
|
115
|
+
},
|
|
89
116
|
];
|
|
@@ -50,12 +50,6 @@ export interface ExportSweepInput {
|
|
|
50
50
|
readonly limit: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export interface ExportSweepBatch {
|
|
54
|
-
readonly ids: readonly string[];
|
|
55
|
-
/** The objects those jobs hold, for the caller to delete before the rows. */
|
|
56
|
-
readonly objectIds: readonly string[];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
53
|
/**
|
|
60
54
|
* The business port: async, database-agnostic, and unaware of SQL. Every method
|
|
61
55
|
* is tenant-scoped except `listPendingJobs`, which crosses workspaces on the
|
|
@@ -83,12 +77,17 @@ export interface ExportRepository {
|
|
|
83
77
|
input: SettleExportJobInput,
|
|
84
78
|
): Promise<ExportJob | null>;
|
|
85
79
|
/** The batch one retention pass removes, chosen once, oldest first. */
|
|
86
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Locks one batch of settled jobs, hands their object ids to `discard`
|
|
82
|
+
* while the rows are held, then deletes the rows; answers how many went.
|
|
83
|
+
* The lock orders the sweep against a pass that rewrites a file under the
|
|
84
|
+
* same lock, so neither can leave a file the other does not see.
|
|
85
|
+
*/
|
|
86
|
+
sweepJobs(
|
|
87
87
|
tenantId: string,
|
|
88
88
|
input: ExportSweepInput,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
deleteJobs(tenantId: string, ids: readonly string[]): Promise<number>;
|
|
89
|
+
discard: (objectIds: readonly string[]) => Promise<void>,
|
|
90
|
+
): Promise<number>;
|
|
92
91
|
/** The whole workspace, oldest first, for the data class export. */
|
|
93
92
|
exportJobs(
|
|
94
93
|
tenantId: string,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DatabaseHandle } from '@flowdular/sdk/database';
|
|
2
|
+
import type { StorageResealCount, StorageResealPort } from '@flowdular/sdk/storage';
|
|
3
|
+
import { EXPORT_OWNER_MODULE } from './export-service.ts';
|
|
4
|
+
|
|
5
|
+
/** Objects walked inside one tenant-scoped transaction. */
|
|
6
|
+
export const STORAGE_ROTATION_BATCH = 200;
|
|
7
|
+
|
|
8
|
+
export const STORAGE_ROTATION_TABLE = 'exports_jobs';
|
|
9
|
+
|
|
10
|
+
export interface StorageRotationReport {
|
|
11
|
+
readonly table: string;
|
|
12
|
+
/** Key id every object should end on: the current key of the storage ring. */
|
|
13
|
+
readonly currentKeyId: string;
|
|
14
|
+
readonly counts: readonly StorageResealCount[];
|
|
15
|
+
readonly tenants: number;
|
|
16
|
+
/** Completed jobs that name a file. */
|
|
17
|
+
readonly objects: number;
|
|
18
|
+
/** Objects on a retired key the ring still holds when the run started. */
|
|
19
|
+
readonly stale: number;
|
|
20
|
+
readonly resealed: number;
|
|
21
|
+
/** Objects under a key id the ring does not hold; left as they are. */
|
|
22
|
+
readonly unknown: number;
|
|
23
|
+
/** Objects that failed authentication; left as they are. */
|
|
24
|
+
readonly refused: number;
|
|
25
|
+
/** Jobs whose file the store no longer holds. */
|
|
26
|
+
readonly missing: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StorageRotationOptions {
|
|
30
|
+
/** Tenant-scoped handle. Every row is read on it. */
|
|
31
|
+
readonly runtime: DatabaseHandle;
|
|
32
|
+
/** Cross-tenant handle. It is granted the routing columns and nothing else. */
|
|
33
|
+
readonly background: DatabaseHandle;
|
|
34
|
+
readonly storage: StorageResealPort;
|
|
35
|
+
readonly apply?: boolean;
|
|
36
|
+
readonly batchSize?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SQL = {
|
|
40
|
+
/* The cross-tenant role sees the job status and not the object id, so the
|
|
41
|
+
inventory names workspaces with completed jobs; the rows that carry a
|
|
42
|
+
file are read under each of them. */
|
|
43
|
+
tenants: `SELECT tenant_id
|
|
44
|
+
FROM exports_jobs
|
|
45
|
+
WHERE status = 'completed'
|
|
46
|
+
GROUP BY tenant_id
|
|
47
|
+
ORDER BY tenant_id`,
|
|
48
|
+
/* Paged by primary key. The rows are locked while their files are
|
|
49
|
+
rewritten, so the retention sweep deletes a row after the batch that
|
|
50
|
+
rewrote its file, never between the read and the write. */
|
|
51
|
+
batch: `SELECT id, object_id
|
|
52
|
+
FROM exports_jobs
|
|
53
|
+
WHERE tenant_id = $1 AND object_id IS NOT NULL AND id > $2
|
|
54
|
+
ORDER BY id
|
|
55
|
+
LIMIT $3
|
|
56
|
+
FOR UPDATE`,
|
|
57
|
+
inventoryBatch: `SELECT id, object_id
|
|
58
|
+
FROM exports_jobs
|
|
59
|
+
WHERE tenant_id = $1 AND object_id IS NOT NULL AND id > $2
|
|
60
|
+
ORDER BY id
|
|
61
|
+
LIMIT $3`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Re-seals every export file that is not on the current storage key. The
|
|
66
|
+
* workspaces are listed once on the cross-tenant role, and each batch of jobs
|
|
67
|
+
* is read under its own tenant; the files they name are read from the store,
|
|
68
|
+
* and the stale ones rewritten in place. It is idempotent: a second run finds
|
|
69
|
+
* nothing to do.
|
|
70
|
+
*/
|
|
71
|
+
export async function rotateExportObjects(
|
|
72
|
+
options: StorageRotationOptions,
|
|
73
|
+
): Promise<StorageRotationReport> {
|
|
74
|
+
const apply = options.apply === true;
|
|
75
|
+
const batchSize = options.batchSize ?? STORAGE_ROTATION_BATCH;
|
|
76
|
+
const inventory = await options.background.transaction(
|
|
77
|
+
(transaction) =>
|
|
78
|
+
transaction.query<{ tenant_id: string }>({ text: SQL.tenants }),
|
|
79
|
+
{ access: 'read' },
|
|
80
|
+
);
|
|
81
|
+
const counts = new Map<string, number>();
|
|
82
|
+
let objects = 0;
|
|
83
|
+
let stale = 0;
|
|
84
|
+
let resealed = 0;
|
|
85
|
+
let unknown = 0;
|
|
86
|
+
let refused = 0;
|
|
87
|
+
let missing = 0;
|
|
88
|
+
for (const { tenant_id: tenantId } of inventory.rows) {
|
|
89
|
+
let cursor = '';
|
|
90
|
+
for (;;) {
|
|
91
|
+
const batch = await options.runtime.transaction(
|
|
92
|
+
async (transaction) => {
|
|
93
|
+
const rows = (
|
|
94
|
+
await transaction.query<{ id: string; object_id: string }>({
|
|
95
|
+
text: apply ? SQL.batch : SQL.inventoryBatch,
|
|
96
|
+
parameters: [tenantId, cursor, batchSize],
|
|
97
|
+
})
|
|
98
|
+
).rows;
|
|
99
|
+
const report = await options.storage.reseal(
|
|
100
|
+
rows.map((row) => ({
|
|
101
|
+
tenantId,
|
|
102
|
+
moduleId: EXPORT_OWNER_MODULE,
|
|
103
|
+
objectId: row.object_id,
|
|
104
|
+
})),
|
|
105
|
+
{ apply },
|
|
106
|
+
);
|
|
107
|
+
return { read: rows.length, last: rows.at(-1)?.id, report };
|
|
108
|
+
},
|
|
109
|
+
{ access: apply ? 'write' : 'read', tenantId },
|
|
110
|
+
);
|
|
111
|
+
objects += batch.read;
|
|
112
|
+
for (const entry of batch.report.counts) {
|
|
113
|
+
counts.set(entry.keyId, (counts.get(entry.keyId) ?? 0) + entry.objects);
|
|
114
|
+
}
|
|
115
|
+
stale += batch.report.stale;
|
|
116
|
+
resealed += batch.report.resealed;
|
|
117
|
+
unknown += batch.report.unknown;
|
|
118
|
+
refused += batch.report.refused;
|
|
119
|
+
missing += batch.report.missing;
|
|
120
|
+
if (batch.read < batchSize || batch.last === undefined) break;
|
|
121
|
+
cursor = batch.last;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
table: STORAGE_ROTATION_TABLE,
|
|
126
|
+
currentKeyId: options.storage.keyId,
|
|
127
|
+
counts: [...counts]
|
|
128
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
129
|
+
.map(([keyId, total]) => ({ keyId, objects: total })),
|
|
130
|
+
tenants: inventory.rows.length,
|
|
131
|
+
objects,
|
|
132
|
+
stale,
|
|
133
|
+
resealed,
|
|
134
|
+
unknown,
|
|
135
|
+
refused,
|
|
136
|
+
missing,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- One row per module whose activation an owner changed for a workspace. A
|
|
2
|
+
-- composed module without a row is active; the row keeps the last decision and
|
|
3
|
+
-- who made it. The primary key is the only lookup: the snapshot reads every row
|
|
4
|
+
-- of one tenant at once.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS system_module_activations (
|
|
6
|
+
tenant_id TEXT NOT NULL,
|
|
7
|
+
module_id TEXT NOT NULL CHECK (length(module_id) BETWEEN 1 AND 128),
|
|
8
|
+
active SMALLINT NOT NULL CHECK (active IN (0, 1)),
|
|
9
|
+
changed_by TEXT NOT NULL CHECK (length(changed_by) BETWEEN 1 AND 64),
|
|
10
|
+
changed_at BIGINT NOT NULL,
|
|
11
|
+
PRIMARY KEY (tenant_id, module_id)
|
|
12
|
+
);
|
|
13
|
+
ALTER TABLE system_module_activations ENABLE ROW LEVEL SECURITY;
|
|
14
|
+
ALTER TABLE system_module_activations FORCE ROW LEVEL SECURITY;
|
|
15
|
+
CREATE POLICY system_module_activations_tenant_policy ON system_module_activations
|
|
16
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
17
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"id": "system.core",
|
|
5
5
|
"package": "@flowdular/module-system",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.8.0",
|
|
7
7
|
"profile": "full",
|
|
8
|
-
"capabilities": ["api", "client", "translations"],
|
|
8
|
+
"capabilities": ["api", "database", "client", "translations"],
|
|
9
9
|
"platform": {
|
|
10
10
|
"server": true,
|
|
11
11
|
"client": true
|
|
12
12
|
},
|
|
13
13
|
"dependencies": [],
|
|
14
|
+
"provides": ["system.modules.v1"],
|
|
14
15
|
"tenancy": "required",
|
|
15
16
|
"locales": ["en", "pl"],
|
|
16
17
|
"stability": "experimental",
|