@flowdular/sdk 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.ai/platform-capabilities.md +2 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/module.json +1 -1
  4. package/.ai/references/catalog/package.json +2 -2
  5. package/.ai/references/catalog/spec/module.yaml +1 -1
  6. package/.ai/references/catalog.provenance.json +6 -6
  7. package/.ai/skills/cli-extension/SKILL.md +1 -1
  8. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  9. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  10. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  11. package/modules/approvals/module.json +1 -1
  12. package/modules/approvals/package.json +1 -1
  13. package/modules/approvals/spec/module.yaml +11 -2
  14. package/modules/approvals/src/domain/capability.ts +12 -0
  15. package/modules/approvals/src/domain/grant.ts +69 -0
  16. package/modules/approvals/src/domain/types.ts +14 -0
  17. package/modules/approvals/src/index.ts +9 -0
  18. package/modules/approvals/src/platform.ts +8 -0
  19. package/modules/approvals/src/server/runtime.ts +4 -0
  20. package/modules/approvals/src/services/approvals-service.ts +80 -0
  21. package/modules/approvals/src/services/database-repository.ts +65 -6
  22. package/modules/approvals/src/services/migration.ts +34 -0
  23. package/modules/approvals/src/services/repository.ts +8 -0
  24. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  25. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  26. package/modules/connectors/module.json +7 -3
  27. package/modules/connectors/package.json +2 -1
  28. package/modules/connectors/spec/module.yaml +2 -1
  29. package/modules/connectors/src/cli/commands.json +17 -0
  30. package/modules/connectors/src/cli/index.ts +126 -0
  31. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  32. package/modules/connectors/src/services/credential-vault.ts +6 -0
  33. package/modules/connectors/src/services/migration.ts +36 -0
  34. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  35. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  36. package/modules/documents/module.json +7 -3
  37. package/modules/documents/package.json +2 -1
  38. package/modules/documents/spec/module.yaml +2 -1
  39. package/modules/documents/src/cli/commands.json +17 -0
  40. package/modules/documents/src/cli/index.ts +145 -0
  41. package/modules/documents/src/services/database-repository.ts +15 -4
  42. package/modules/documents/src/services/documents-service.ts +13 -9
  43. package/modules/documents/src/services/migration.ts +35 -0
  44. package/modules/documents/src/services/repository.ts +12 -2
  45. package/modules/documents/src/services/storage-rotation.ts +157 -0
  46. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  47. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  48. package/modules/exports/module.json +7 -3
  49. package/modules/exports/package.json +2 -1
  50. package/modules/exports/spec/module.yaml +2 -1
  51. package/modules/exports/src/cli/commands.json +17 -0
  52. package/modules/exports/src/cli/index.ts +145 -0
  53. package/modules/exports/src/server/index.ts +0 -1
  54. package/modules/exports/src/services/data-classes.ts +16 -13
  55. package/modules/exports/src/services/database-repository.ts +30 -32
  56. package/modules/exports/src/services/migration.ts +27 -0
  57. package/modules/exports/src/services/repository.ts +9 -10
  58. package/modules/exports/src/services/storage-rotation.ts +138 -0
  59. package/package.json +1 -1
  60. package/packages/contracts/src/index.ts +1 -1
  61. package/packages/database/src/backup.ts +1 -0
  62. package/packages/database/src/migrations.ts +7 -0
  63. package/packages/harness/src/runtime.ts +169 -10
  64. package/packages/harness/src/tool-adapters.ts +6 -13
  65. package/packages/kernel/src/approval-grant.ts +310 -0
  66. package/packages/kernel/src/index.ts +20 -0
  67. package/packages/storage/src/envelope.ts +70 -21
  68. package/packages/storage/src/index.ts +7 -1
  69. package/packages/storage/src/port.ts +12 -1
  70. package/packages/storage/src/reseal.ts +128 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "protocolVersion": 1,
3
+ "moduleId": "exports.core",
4
+ "commands": [
5
+ {
6
+ "path": ["exports", "secrets-rotate"],
7
+ "capability": {
8
+ "id": "exports.storage.rotate",
9
+ "version": 1,
10
+ "summary": "Re-seal stored export files with the current storage encryption key.",
11
+ "risk": "process",
12
+ "requiresApprovedSpec": false,
13
+ "supportsDryRun": true
14
+ }
15
+ }
16
+ ]
17
+ }
@@ -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;
@@ -37,7 +37,6 @@ export type {
37
37
  ExportJobPage,
38
38
  ExportJobQuery,
39
39
  ExportRepository,
40
- ExportSweepBatch,
41
40
  ExportSweepInput,
42
41
  SettleExportJobInput,
43
42
  } from '../services/repository.ts';
@@ -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
- const batch = await open.claimSweepBatch(tenantId, {
37
- settledBefore: cutoff.getTime(),
38
- /* The platform's number is the caller's; this is the module's own
39
- bound on one pass. */
40
- limit: Math.min(Math.max(Math.trunc(limit), 1), EXPORT_SWEEP_JOBS),
41
- });
42
- if (batch.ids.length === 0) return { removed: 0 };
43
- /* The file goes before the row it belongs to. An interrupted pass then
44
- leaves a row naming a file that is gone, which the next pass removes
45
- and a read answers as gone; the other order would leave a file
46
- nothing references and no pass could ever find. */
47
- await (await service()).discardObjects(tenantId, batch.objectIds);
48
- return { removed: await open.deleteJobs(tenantId, batch.ids) };
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 its objects come back with it: deleting the
71
- files and then the rows from one chosen set means no pass can delete a
72
- file whose row a differently ordered second query kept. */
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 claimSweepBatch(
322
+ async sweepJobs(
323
323
  tenantId: string,
324
324
  input: ExportSweepInput,
325
- ): Promise<ExportSweepBatch> {
326
- const result = await this.#handles.runtime.transaction(
327
- (transaction) =>
328
- transaction.query<{ id: string; object_id: string | null }>({
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
- { access: 'read', tenantId },
333
- );
334
- const ids: string[] = [];
335
- const objectIds: string[] = [];
336
- for (const row of result.rows) {
337
- ids.push(row.id);
338
- if (row.object_id !== null) objectIds.push(row.object_id);
339
- }
340
- return { ids, objectIds };
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
- claimSweepBatch(
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
- ): Promise<ExportSweepBatch>;
90
- /** Removes the jobs of a chosen batch; answers how many rows went. */
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Flowdular platform SDK: server, client, UI, data, agents and core modules through separate exports.",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@
4
4
  removed or changed. The surface is pinned by
5
5
  packages/kernel/platform-api.snapshot.d.ts; `pnpm platform-api:check` fails
6
6
  when the surface changes without a bump here. */
7
- export const PLATFORM_API_VERSION = '0.1.6';
7
+ export const PLATFORM_API_VERSION = '0.1.8';
8
8
 
9
9
  /* The workspace time zone: one tenant setting, declared by the module named
10
10
  here and read by any module that shows or schedules a local time. The id, the
@@ -14,6 +14,7 @@ export const BACKUP_MANIFEST_VERSION = 1;
14
14
  export const BACKUP_KEY_VARIABLES = Object.freeze([
15
15
  'FD_AGENT_CREDENTIAL_KEY',
16
16
  'FD_AGENT_RUN_GRANT_KEY',
17
+ 'FD_APPROVAL_GRANT_KEY',
17
18
  'FD_WORKFLOWS_PAYLOAD_KEY',
18
19
  'FD_WORKFLOWS_CURSOR_KEY',
19
20
  'FD_AUTH_MFA_KEY',
@@ -10,6 +10,8 @@ import {
10
10
  } from './contracts.ts';
11
11
 
12
12
  export const DATABASE_MIGRATION_LEDGER = '_coreloom_migrations_v2';
13
+ /** The advisory lock every module migration takes before its own. */
14
+ export const DATABASE_MIGRATION_LEDGER_LOCK = 'coreloom.migrations';
13
15
 
14
16
  /* Line endings and surrounding blank space are editor noise; everything else,
15
17
  including whitespace inside the SQL, is part of the checksum. Changing this
@@ -377,6 +379,11 @@ export async function runDatabaseMigrations(
377
379
 
378
380
  return database.transaction(
379
381
  async (transaction) => {
382
+ /* Every module shares the ledger table, and this transaction is
383
+ serializable, so two modules migrating at the same boot collide on
384
+ it with a serialization failure. The ledger lock serialises boots
385
+ across modules; the namespace lock keeps the per-module contract. */
386
+ await transaction.acquireMigrationLock(DATABASE_MIGRATION_LEDGER_LOCK);
380
387
  await transaction.acquireMigrationLock(namespace);
381
388
  await transaction.executeScript(
382
389
  database.capabilities.sql.migrationLedgerDdl(DATABASE_MIGRATION_LEDGER),