@flowdular/sdk 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.ai/platform-capabilities.md +4 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  4. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  5. package/.ai/references/catalog/module.json +11 -1
  6. package/.ai/references/catalog/package.json +2 -2
  7. package/.ai/references/catalog/spec/module.yaml +25 -4
  8. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  9. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  10. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  11. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  12. package/.ai/references/catalog/src/client/api.ts +124 -36
  13. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  14. package/.ai/references/catalog/src/client/state.ts +169 -3
  15. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  16. package/.ai/references/catalog/src/domain/types.ts +20 -0
  17. package/.ai/references/catalog/src/platform.ts +20 -0
  18. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  19. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  20. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  21. package/.ai/references/catalog/src/services/migration.ts +27 -1
  22. package/.ai/references/catalog/src/services/repository.ts +31 -2
  23. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  24. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  25. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  26. package/.ai/references/catalog/tests/export.test.ts +134 -0
  27. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  28. package/.ai/references/catalog/tests/list.test.ts +217 -0
  29. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  30. package/.ai/references/catalog/tests/module.test.ts +2 -1
  31. package/.ai/references/catalog/tests/support/database.ts +14 -0
  32. package/.ai/references/catalog/translations/en.json +35 -4
  33. package/.ai/references/catalog/translations/pl.json +35 -4
  34. package/.ai/references/catalog.provenance.json +34 -26
  35. package/.ai/skills/cli-extension/SKILL.md +1 -1
  36. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  37. package/README.md +1 -1
  38. package/assets/flowdular-banner.webp +0 -0
  39. package/modules/access/module.json +1 -1
  40. package/modules/access/spec/module.yaml +1 -1
  41. package/modules/agents/module.json +1 -1
  42. package/modules/agents/spec/module.yaml +1 -1
  43. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  44. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  45. package/modules/approvals/module.json +2 -2
  46. package/modules/approvals/package.json +1 -1
  47. package/modules/approvals/spec/module.yaml +12 -3
  48. package/modules/approvals/src/domain/capability.ts +12 -0
  49. package/modules/approvals/src/domain/grant.ts +69 -0
  50. package/modules/approvals/src/domain/types.ts +14 -0
  51. package/modules/approvals/src/index.ts +9 -0
  52. package/modules/approvals/src/platform.ts +8 -0
  53. package/modules/approvals/src/server/runtime.ts +4 -0
  54. package/modules/approvals/src/services/approvals-service.ts +80 -0
  55. package/modules/approvals/src/services/database-repository.ts +65 -6
  56. package/modules/approvals/src/services/migration.ts +34 -0
  57. package/modules/approvals/src/services/repository.ts +8 -0
  58. package/modules/audit/module.json +1 -1
  59. package/modules/audit/spec/module.yaml +1 -1
  60. package/modules/auth/module.json +2 -2
  61. package/modules/auth/package.json +1 -1
  62. package/modules/auth/spec/module.yaml +2 -2
  63. package/modules/auth/src/middleware/authentication.ts +5 -1
  64. package/modules/auth/src/services/auth-service.ts +26 -0
  65. package/modules/auth/src/services/database-repository.ts +9 -0
  66. package/modules/auth/src/services/repository.ts +2 -0
  67. package/modules/automations/module.json +1 -1
  68. package/modules/automations/spec/module.yaml +1 -1
  69. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  70. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  71. package/modules/connectors/module.json +8 -4
  72. package/modules/connectors/package.json +2 -1
  73. package/modules/connectors/spec/module.yaml +3 -2
  74. package/modules/connectors/src/cli/commands.json +17 -0
  75. package/modules/connectors/src/cli/index.ts +126 -0
  76. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  77. package/modules/connectors/src/services/credential-vault.ts +6 -0
  78. package/modules/connectors/src/services/migration.ts +36 -0
  79. package/modules/directory/module.json +1 -1
  80. package/modules/directory/spec/module.yaml +1 -1
  81. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  82. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  83. package/modules/documents/module.json +8 -4
  84. package/modules/documents/package.json +2 -1
  85. package/modules/documents/spec/module.yaml +3 -2
  86. package/modules/documents/src/cli/commands.json +17 -0
  87. package/modules/documents/src/cli/index.ts +145 -0
  88. package/modules/documents/src/services/database-repository.ts +15 -4
  89. package/modules/documents/src/services/documents-service.ts +13 -9
  90. package/modules/documents/src/services/migration.ts +35 -0
  91. package/modules/documents/src/services/repository.ts +12 -2
  92. package/modules/documents/src/services/storage-rotation.ts +157 -0
  93. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  94. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  95. package/modules/exports/module.json +8 -4
  96. package/modules/exports/package.json +2 -1
  97. package/modules/exports/spec/module.yaml +3 -2
  98. package/modules/exports/src/cli/commands.json +17 -0
  99. package/modules/exports/src/cli/index.ts +145 -0
  100. package/modules/exports/src/server/index.ts +0 -1
  101. package/modules/exports/src/services/data-classes.ts +16 -13
  102. package/modules/exports/src/services/database-repository.ts +30 -32
  103. package/modules/exports/src/services/migration.ts +27 -0
  104. package/modules/exports/src/services/repository.ts +9 -10
  105. package/modules/exports/src/services/storage-rotation.ts +138 -0
  106. package/modules/import/module.json +1 -1
  107. package/modules/import/spec/module.yaml +1 -1
  108. package/modules/metering/module.json +1 -1
  109. package/modules/metering/spec/module.yaml +1 -1
  110. package/modules/notifications/module.json +1 -1
  111. package/modules/notifications/spec/module.yaml +1 -1
  112. package/modules/reports/module.json +1 -1
  113. package/modules/reports/spec/module.yaml +1 -1
  114. package/modules/sandbox/module.json +1 -1
  115. package/modules/sandbox/spec/module.yaml +1 -1
  116. package/modules/search/module.json +1 -1
  117. package/modules/search/spec/module.yaml +1 -1
  118. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  119. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  120. package/modules/system/module.json +3 -2
  121. package/modules/system/package.json +2 -2
  122. package/modules/system/spec/module.yaml +98 -1
  123. package/modules/system/src/client/ModulesView.tsrx +101 -8
  124. package/modules/system/src/client/api.ts +52 -11
  125. package/modules/system/src/client/module-columns.tsrx +32 -6
  126. package/modules/system/src/client/state.ts +26 -0
  127. package/modules/system/src/domain/modules.ts +93 -0
  128. package/modules/system/src/index.ts +13 -0
  129. package/modules/system/src/platform.ts +41 -1
  130. package/modules/system/src/server/capability.ts +6 -0
  131. package/modules/system/src/server/endpoints.ts +125 -5
  132. package/modules/system/src/server/index.ts +26 -0
  133. package/modules/system/src/server/module-catalog.ts +19 -0
  134. package/modules/system/src/server/runtime.ts +102 -0
  135. package/modules/system/src/services/database-repository.ts +91 -0
  136. package/modules/system/src/services/migration.ts +39 -0
  137. package/modules/system/src/services/module-activation-service.ts +245 -0
  138. package/modules/system/src/services/repository.ts +17 -0
  139. package/modules/system/translations/en.json +17 -1
  140. package/modules/system/translations/pl.json +17 -1
  141. package/modules/users/module.json +2 -2
  142. package/modules/users/package.json +1 -1
  143. package/modules/users/spec/module.yaml +6 -9
  144. package/modules/users/src/client/UsersView.tsrx +27 -13
  145. package/modules/users/src/client/member-columns.tsrx +13 -4
  146. package/modules/users/src/client/state.ts +16 -0
  147. package/modules/users/src/services/users-service.ts +3 -0
  148. package/modules/users/translations/en.json +2 -1
  149. package/modules/users/translations/pl.json +2 -1
  150. package/modules/workflows/module.json +2 -2
  151. package/modules/workflows/package.json +1 -1
  152. package/modules/workflows/spec/module.yaml +2 -2
  153. package/modules/workflows/src/services/database-repository.ts +93 -53
  154. package/package.json +1 -1
  155. package/packages/client/package.json +1 -0
  156. package/packages/client/src/ApplicationShell.tsrx +46 -5
  157. package/packages/client/src/index.ts +5 -0
  158. package/packages/client/src/shell/modules.ts +49 -0
  159. package/packages/client/src/shell/types.ts +3 -0
  160. package/packages/contracts/src/index.ts +11 -1
  161. package/packages/database/src/backup.ts +1 -0
  162. package/packages/database/src/migrations.ts +7 -0
  163. package/packages/harness/src/runtime.ts +169 -10
  164. package/packages/harness/src/tool-adapters.ts +6 -13
  165. package/packages/kernel/src/approval-grant.ts +310 -0
  166. package/packages/kernel/src/index.ts +20 -0
  167. package/packages/server/src/endpoint.ts +17 -1
  168. package/packages/server/src/index.ts +10 -0
  169. package/packages/server/src/module-activation.ts +69 -0
  170. package/packages/storage/src/envelope.ts +70 -21
  171. package/packages/storage/src/index.ts +7 -1
  172. package/packages/storage/src/port.ts +12 -1
  173. package/packages/storage/src/reseal.ts +128 -0
  174. package/assets/flowdular-banner.png +0 -0
@@ -2,14 +2,54 @@ import type {
2
2
  PlatformServerComposition,
3
3
  PlatformServerContext,
4
4
  } from '@flowdular/sdk/modules/auth/server';
5
+ import { installModuleActivationGate } from '@flowdular/sdk/server';
6
+ import {
7
+ composedModules,
8
+ SYSTEM_MODULES_CAPABILITY,
9
+ type ComposedModule,
10
+ } from './domain/modules.ts';
5
11
  import { createSystemRoutes } from './server/endpoints.ts';
12
+ import { readModuleCatalog } from './server/module-catalog.ts';
13
+ import { createSystemRuntime } from './server/runtime.ts';
14
+ import type { SystemModulesCapability } from './server/capability.ts';
6
15
  import { SYSTEM_MODULE_SETTINGS } from './settings.ts';
7
16
 
8
17
  export function createServerComposition(
9
18
  context: PlatformServerContext,
10
19
  ): PlatformServerComposition {
20
+ /* The composed set is baked at build, so the manifests are read once per
21
+ composition, which in development is once per reload. */
22
+ let composed: readonly ComposedModule[] | null = null;
23
+ const modules = () =>
24
+ (composed ??= composedModules(
25
+ readModuleCatalog(context.workspaceRoot).filter((entry) => entry.enabled),
26
+ ));
27
+ const runtime = createSystemRuntime({
28
+ databases: context.databases,
29
+ purpose:
30
+ context.environment.NODE_ENV === 'test'
31
+ ? 'test'
32
+ : context.environment.NODE_ENV === 'production'
33
+ ? 'runtime'
34
+ : 'preview',
35
+ modules,
36
+ audit: async (actor, change) =>
37
+ (await context.auth.service()).recordModuleActivation(actor, change),
38
+ });
39
+ const capability: SystemModulesCapability = {
40
+ isActive: async (tenantId, moduleId) =>
41
+ (await runtime.service()).isActive(tenantId, moduleId),
42
+ activeIds: async (tenantId) =>
43
+ (await runtime.service()).activeIds(tenantId),
44
+ };
45
+ context.capabilities.register(SYSTEM_MODULES_CAPABILITY, capability);
46
+ installModuleActivationGate(capability);
11
47
  return {
12
- routes: createSystemRoutes(context),
48
+ routes: createSystemRoutes({ ...context, activation: runtime }),
13
49
  settings: SYSTEM_MODULE_SETTINGS,
50
+ dispose: async () => {
51
+ installModuleActivationGate(null);
52
+ await runtime.dispose();
53
+ },
14
54
  };
15
55
  }
@@ -0,0 +1,6 @@
1
+ /** `system.modules.v1`: which composed modules are active in a workspace. */
2
+ export interface SystemModulesCapability {
3
+ isActive(tenantId: string, moduleId: string): Promise<boolean>;
4
+ /** Sorted ids of the composed modules active in the workspace. */
5
+ activeIds(tenantId: string): Promise<readonly string[]>;
6
+ }
@@ -26,12 +26,57 @@ import {
26
26
  SYSTEM_MODULE_ID,
27
27
  TENANT_TIME_ZONE_KEY,
28
28
  } from '../domain/time-zone.ts';
29
- import { readModuleCatalog } from './module-catalog.ts';
29
+ import type { ModuleActivationEntry } from '../domain/modules.ts';
30
+ import { ModuleActivationError } from '../services/module-activation-service.ts';
31
+ import {
32
+ readModuleCatalog,
33
+ type ModuleCatalogEntry,
34
+ } from './module-catalog.ts';
35
+ import type { SystemRuntime } from './runtime.ts';
30
36
 
31
37
  export interface SystemRouteOptions {
32
38
  readonly workspaceRoot: string;
33
39
  readonly auth: AuthRuntime;
34
40
  readonly settings: ModuleSettingsRuntime;
41
+ /** Per-workspace activation of the composed modules. */
42
+ readonly activation: SystemRuntime;
43
+ }
44
+
45
+ /** A catalog row with its activation in the asking principal's workspace. */
46
+ export interface ModuleCatalogPayload extends ModuleCatalogEntry {
47
+ readonly active: boolean;
48
+ readonly optional: boolean;
49
+ readonly dependents: readonly string[];
50
+ }
51
+
52
+ /* A module the workspace holds but the application does not compose has no
53
+ activation to speak of: it is listed as available and never active. */
54
+ function catalogPayload(
55
+ entry: ModuleCatalogEntry,
56
+ activation: ModuleActivationEntry | undefined,
57
+ ): ModuleCatalogPayload {
58
+ return {
59
+ ...entry,
60
+ active: activation?.active ?? false,
61
+ optional: activation?.optional ?? true,
62
+ dependents: activation?.dependents ?? [],
63
+ };
64
+ }
65
+
66
+ function activationProblem(error: unknown): Response {
67
+ if (error instanceof ModuleActivationError) {
68
+ return jsonResponse(
69
+ {
70
+ error: {
71
+ code: error.code,
72
+ message: error.message,
73
+ modules: error.modules,
74
+ },
75
+ },
76
+ error.status,
77
+ );
78
+ }
79
+ return problemResponse(error, 'The module activation request failed.');
35
80
  }
36
81
 
37
82
  const MAIL_TRANSPORT_REQUIRED =
@@ -236,8 +281,9 @@ async function readActivity(
236
281
  return keys.map((date) => ({ date, count: counts.get(date) ?? 0 }));
237
282
  }
238
283
 
239
- /* Metadata only: manifests and specifications from the workspace. Enabling or
240
- disabling a module stays a CLI operation with its own approval trail. */
284
+ /* Manifests and specifications from the workspace, with the per-workspace
285
+ activation of the composed ones. Enabling or disabling a module stays a CLI
286
+ operation with its own approval trail. */
241
287
  export function createSystemRoutes(options: SystemRouteOptions) {
242
288
  const modules = defineEndpoint({
243
289
  id: 'system.modules.list',
@@ -245,10 +291,18 @@ export function createSystemRoutes(options: SystemRouteOptions) {
245
291
  methods: ['GET'],
246
292
  access: { kind: 'permission', permission: SYSTEM_PERMISSIONS.modulesRead },
247
293
  resolveIdentity: endpointIdentityFromContext,
248
- handler: () => {
294
+ handler: async ({ octane }) => {
249
295
  try {
296
+ const principal = principalFromContext(octane)!;
297
+ const activations = new Map(
298
+ (
299
+ await (await options.activation.service()).list(principal.tenantId)
300
+ ).map((entry) => [entry.id, entry]),
301
+ );
250
302
  return jsonResponse({
251
- modules: readModuleCatalog(options.workspaceRoot),
303
+ modules: readModuleCatalog(options.workspaceRoot).map((entry) =>
304
+ catalogPayload(entry, activations.get(entry.id)),
305
+ ),
252
306
  commands: {
253
307
  enable: 'pnpm flowdular module enable <id> --apply',
254
308
  disable: 'pnpm flowdular module disable <id> --apply',
@@ -263,6 +317,66 @@ export function createSystemRoutes(options: SystemRouteOptions) {
263
317
  },
264
318
  });
265
319
 
320
+ const activeModules = defineEndpoint({
321
+ id: 'system.modules.active',
322
+ path: '/api/system/modules/active',
323
+ methods: ['GET'],
324
+ access: {
325
+ kind: 'permission',
326
+ permission: SYSTEM_PERMISSIONS.workspaceAccess,
327
+ },
328
+ resolveIdentity: endpointIdentityFromContext,
329
+ handler: async ({ octane }) => {
330
+ try {
331
+ const principal = principalFromContext(octane)!;
332
+ const service = await options.activation.service();
333
+ return jsonResponse({
334
+ modules: await service.activeIds(principal.tenantId),
335
+ });
336
+ } catch (error) {
337
+ return activationProblem(error);
338
+ }
339
+ },
340
+ });
341
+
342
+ const activation = (id: string, path: string, active: boolean) =>
343
+ defineEndpoint({
344
+ id,
345
+ path,
346
+ methods: ['POST'],
347
+ access: {
348
+ kind: 'permission',
349
+ permission: SYSTEM_PERMISSIONS.settingsManage,
350
+ },
351
+ resolveIdentity: endpointIdentityFromContext,
352
+ handler: async ({ octane }) => {
353
+ const denial = sessionMutationDenial(octane, options.auth);
354
+ if (denial) return denial;
355
+ try {
356
+ const principal = principalFromContext(octane)!;
357
+ const body = await readJsonObject(octane.request);
358
+ const moduleId = requiredString(body, 'moduleId', { max: 128 });
359
+ const service = await options.activation.service();
360
+ const entry = active
361
+ ? await service.activate(principal, moduleId)
362
+ : await service.deactivate(principal, moduleId);
363
+ return jsonResponse({ module: entry });
364
+ } catch (error) {
365
+ return activationProblem(error);
366
+ }
367
+ },
368
+ });
369
+ const activateModule = activation(
370
+ 'system.modules.activate',
371
+ '/api/system/modules/activate',
372
+ true,
373
+ );
374
+ const deactivateModule = activation(
375
+ 'system.modules.deactivate',
376
+ '/api/system/modules/deactivate',
377
+ false,
378
+ );
379
+
266
380
  /* Module names come from the workspace specs; the catalog is read once per
267
381
  composition, which in development is once per reload. */
268
382
  let names: ReadonlyMap<
@@ -409,6 +523,9 @@ export function createSystemRoutes(options: SystemRouteOptions) {
409
523
 
410
524
  return [
411
525
  modules.serverRoute,
526
+ activeModules.serverRoute,
527
+ activateModule.serverRoute,
528
+ deactivateModule.serverRoute,
412
529
  overview.serverRoute,
413
530
  settingsList.serverRoute,
414
531
  settingsUpdate.serverRoute,
@@ -417,6 +534,9 @@ export function createSystemRoutes(options: SystemRouteOptions) {
417
534
 
418
535
  export const endpoints = [
419
536
  'system.modules.list',
537
+ 'system.modules.active',
538
+ 'system.modules.activate',
539
+ 'system.modules.deactivate',
420
540
  'system.overview.read',
421
541
  'system.settings.list',
422
542
  'system.settings.update',
@@ -1,5 +1,6 @@
1
1
  export { createSystemRoutes, endpoints } from './endpoints.ts';
2
2
  export type {
3
+ ModuleCatalogPayload,
3
4
  OverviewActivityPoint,
4
5
  OverviewModulePoint,
5
6
  SettingsEntryPayload,
@@ -9,3 +10,28 @@ export type {
9
10
  } from './endpoints.ts';
10
11
  export { readModuleCatalog } from './module-catalog.ts';
11
12
  export type { ModuleCatalogEntry } from './module-catalog.ts';
13
+ export type { SystemModulesCapability } from './capability.ts';
14
+ export { createSystemRuntime } from './runtime.ts';
15
+ export type { SystemRuntime, SystemRuntimeOptions } from './runtime.ts';
16
+ export {
17
+ DEFAULT_SNAPSHOT_TENANTS,
18
+ DEFAULT_SNAPSHOT_TTL_MS,
19
+ ModuleActivationError,
20
+ ModuleActivationService,
21
+ } from '../services/module-activation-service.ts';
22
+ export type {
23
+ ModuleActivationErrorCode,
24
+ ModuleActivationServiceOptions,
25
+ } from '../services/module-activation-service.ts';
26
+ export {
27
+ DatabaseModuleActivationRepository,
28
+ migrateSystemDatabase,
29
+ } from '../services/database-repository.ts';
30
+ export {
31
+ databaseMigrations,
32
+ SYSTEM_TENANT_TABLES,
33
+ } from '../services/migration.ts';
34
+ export type {
35
+ ModuleActivationRecord,
36
+ ModuleActivationRepository,
37
+ } from '../services/repository.ts';
@@ -16,6 +16,11 @@ export interface ModuleCatalogEntry {
16
16
  readonly platform: { readonly server: boolean; readonly client: boolean };
17
17
  readonly enabled: boolean;
18
18
  readonly directory: string;
19
+ /** Declared module dependencies, by id. */
20
+ readonly dependencies: readonly string[];
21
+ readonly provides: readonly string[];
22
+ /** Capability ids the module requires, optional requirements excluded. */
23
+ readonly requires: readonly string[];
19
24
  }
20
25
 
21
26
  interface Manifest {
@@ -23,6 +28,9 @@ interface Manifest {
23
28
  version?: unknown;
24
29
  capabilities?: unknown;
25
30
  platform?: { server?: unknown; client?: unknown };
31
+ dependencies?: { id?: unknown }[];
32
+ provides?: unknown;
33
+ requires?: { id?: unknown; optional?: unknown }[];
26
34
  }
27
35
 
28
36
  interface Spec {
@@ -111,6 +119,17 @@ export function readModuleCatalog(
111
119
  },
112
120
  enabled: enabled.has(manifest.id),
113
121
  directory,
122
+ dependencies: strings(
123
+ (Array.isArray(manifest.dependencies) ? manifest.dependencies : []).map(
124
+ (dependency) => dependency?.id,
125
+ ),
126
+ ),
127
+ provides: strings(manifest.provides),
128
+ requires: strings(
129
+ (Array.isArray(manifest.requires) ? manifest.requires : [])
130
+ .filter((requirement) => requirement?.optional !== true)
131
+ .map((requirement) => requirement?.id),
132
+ ),
114
133
  });
115
134
  }
116
135
  return entries;
@@ -0,0 +1,102 @@
1
+ import type {
2
+ DatabaseAdapterLease,
3
+ DatabaseProvider,
4
+ DatabaseProviderRequest,
5
+ } from '@flowdular/sdk/database';
6
+ import {
7
+ DATABASE_CAPABILITY_IDS,
8
+ DATABASE_DIALECT_IDS,
9
+ } from '@flowdular/sdk/database';
10
+ import {
11
+ DatabaseModuleActivationRepository,
12
+ migrateSystemDatabase,
13
+ } from '../services/database-repository.ts';
14
+ import {
15
+ ModuleActivationService,
16
+ type ModuleActivationServiceOptions,
17
+ } from '../services/module-activation-service.ts';
18
+ import type { ModuleActivationRepository } from '../services/repository.ts';
19
+
20
+ export interface SystemRuntimeOptions
21
+ extends Omit<ModuleActivationServiceOptions, 'repository'> {
22
+ /** Platform-owned provider. Modules never receive a DSN or a pool. */
23
+ readonly databases: DatabaseProvider;
24
+ readonly purpose: Exclude<DatabaseProviderRequest['purpose'], 'migration'>;
25
+ /** Supplied by tests that own the lease themselves. */
26
+ readonly repository?: ModuleActivationRepository;
27
+ }
28
+
29
+ export interface SystemRuntime {
30
+ service(): Promise<ModuleActivationService>;
31
+ dispose(): Promise<void>;
32
+ }
33
+
34
+ export function createSystemRuntime(
35
+ options: SystemRuntimeOptions,
36
+ ): SystemRuntime {
37
+ let disposed = false;
38
+ let runtimeLeasePromise: Promise<DatabaseAdapterLease> | undefined;
39
+ let servicePromise: Promise<ModuleActivationService> | undefined;
40
+ const { databases, purpose, repository, ...serviceOptions } = options;
41
+
42
+ const build = (store: ModuleActivationRepository): ModuleActivationService =>
43
+ new ModuleActivationService({ ...serviceOptions, repository: store });
44
+
45
+ /* Schema work runs on the migrator role and that lease is released before the
46
+ runtime one is taken, so request handling never holds a schema owner. */
47
+ const initialize = async (): Promise<ModuleActivationService> => {
48
+ if (repository) return build(repository);
49
+ const migrationLease = await databases.acquire({
50
+ namespace: 'system.core',
51
+ purpose: 'migration',
52
+ requirements: {
53
+ dialectIds: [DATABASE_DIALECT_IDS.postgresql],
54
+ capabilities: [
55
+ DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
56
+ DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
57
+ DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
58
+ ],
59
+ },
60
+ });
61
+ try {
62
+ await migrateSystemDatabase(migrationLease.database);
63
+ } finally {
64
+ await migrationLease.release();
65
+ }
66
+ runtimeLeasePromise = databases.acquire({
67
+ namespace: 'system.core',
68
+ purpose,
69
+ requirements: {
70
+ dialectIds: [DATABASE_DIALECT_IDS.postgresql],
71
+ capabilities: [
72
+ DATABASE_CAPABILITY_IDS.TRANSACTIONS,
73
+ DATABASE_CAPABILITY_IDS.TENANT_CONTEXT,
74
+ ],
75
+ },
76
+ });
77
+ const lease = await runtimeLeasePromise;
78
+ return build(new DatabaseModuleActivationRepository(lease.database));
79
+ };
80
+
81
+ return {
82
+ service: () => {
83
+ if (disposed) {
84
+ return Promise.reject(new Error('System runtime is disposed.'));
85
+ }
86
+ servicePromise ??= initialize();
87
+ return servicePromise;
88
+ },
89
+ async dispose() {
90
+ if (disposed) return;
91
+ disposed = true;
92
+ if (!runtimeLeasePromise) {
93
+ await servicePromise?.catch(() => undefined);
94
+ }
95
+ if (!runtimeLeasePromise) return;
96
+ const lease = await runtimeLeasePromise;
97
+ await lease.release();
98
+ runtimeLeasePromise = undefined;
99
+ servicePromise = undefined;
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,91 @@
1
+ import type { DatabaseHandle } from '@flowdular/sdk/database';
2
+ import { runDatabaseMigrations } from '@flowdular/sdk/database';
3
+ import { databaseMigrations } from './migration.ts';
4
+ import type {
5
+ ModuleActivationRecord,
6
+ ModuleActivationRepository,
7
+ } from './repository.ts';
8
+
9
+ interface ActivationRow {
10
+ tenant_id: string;
11
+ module_id: string;
12
+ active: number | bigint | string;
13
+ changed_by: string;
14
+ changed_at: number | bigint | string;
15
+ }
16
+
17
+ const SQL = {
18
+ list: `SELECT tenant_id, module_id, active, changed_by, changed_at
19
+ FROM system_module_activations
20
+ WHERE tenant_id = $1
21
+ ORDER BY module_id`,
22
+ set: `INSERT INTO system_module_activations
23
+ (tenant_id, module_id, active, changed_by, changed_at)
24
+ VALUES ($1, $2, $3, $4, $5)
25
+ ON CONFLICT (tenant_id, module_id) DO UPDATE
26
+ SET active = EXCLUDED.active,
27
+ changed_by = EXCLUDED.changed_by,
28
+ changed_at = EXCLUDED.changed_at
29
+ WHERE system_module_activations.tenant_id = $1`,
30
+ } as const;
31
+
32
+ /* PostgreSQL returns BIGINT as a string, and a flag column as a number this
33
+ module compares only after normalizing. */
34
+ function integer(value: number | bigint | string, field: string): number {
35
+ const normalized = Number(value);
36
+ if (!Number.isSafeInteger(normalized) || normalized < 0) {
37
+ throw new Error(`The system database returned an invalid ${field}.`);
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function fromRow(row: ActivationRow): ModuleActivationRecord {
43
+ return {
44
+ tenantId: row.tenant_id,
45
+ moduleId: row.module_id,
46
+ active: integer(row.active, 'flag') === 1,
47
+ changedBy: row.changed_by,
48
+ changedAt: integer(row.changed_at, 'timestamp'),
49
+ };
50
+ }
51
+
52
+ export class DatabaseModuleActivationRepository
53
+ implements ModuleActivationRepository
54
+ {
55
+ constructor(private readonly database: DatabaseHandle) {}
56
+
57
+ async list(tenantId: string): Promise<readonly ModuleActivationRecord[]> {
58
+ const result = await this.database.transaction(
59
+ (transaction) =>
60
+ transaction.query<ActivationRow>({
61
+ text: SQL.list,
62
+ parameters: [tenantId],
63
+ }),
64
+ { access: 'read', tenantId },
65
+ );
66
+ return result.rows.map(fromRow);
67
+ }
68
+
69
+ async set(record: ModuleActivationRecord): Promise<void> {
70
+ await this.database.transaction(
71
+ (transaction) =>
72
+ transaction.execute({
73
+ text: SQL.set,
74
+ parameters: [
75
+ record.tenantId,
76
+ record.moduleId,
77
+ record.active ? 1 : 0,
78
+ record.changedBy,
79
+ record.changedAt,
80
+ ],
81
+ }),
82
+ { access: 'write', tenantId: record.tenantId },
83
+ );
84
+ }
85
+ }
86
+
87
+ export async function migrateSystemDatabase(
88
+ database: DatabaseHandle,
89
+ ): Promise<void> {
90
+ await runDatabaseMigrations(database, 'system.core', databaseMigrations);
91
+ }
@@ -0,0 +1,39 @@
1
+ import type { DatabaseMigration } from '@flowdular/sdk/database';
2
+ import { postgresTenantTableState } from '@flowdular/sdk/database';
3
+
4
+ /* Mirrors migrations/0001_system_module_activations.up.sql byte for byte;
5
+ tests/migrations.test.ts fails on drift. */
6
+ export const SYSTEM_MIGRATION_001 = `-- One row per module whose activation an owner changed for a workspace. A
7
+ -- composed module without a row is active; the row keeps the last decision and
8
+ -- who made it. The primary key is the only lookup: the snapshot reads every row
9
+ -- of one tenant at once.
10
+ CREATE TABLE IF NOT EXISTS system_module_activations (
11
+ tenant_id TEXT NOT NULL,
12
+ module_id TEXT NOT NULL CHECK (length(module_id) BETWEEN 1 AND 128),
13
+ active SMALLINT NOT NULL CHECK (active IN (0, 1)),
14
+ changed_by TEXT NOT NULL CHECK (length(changed_by) BETWEEN 1 AND 64),
15
+ changed_at BIGINT NOT NULL,
16
+ PRIMARY KEY (tenant_id, module_id)
17
+ );
18
+ ALTER TABLE system_module_activations ENABLE ROW LEVEL SECURITY;
19
+ ALTER TABLE system_module_activations FORCE ROW LEVEL SECURITY;
20
+ CREATE POLICY system_module_activations_tenant_policy ON system_module_activations
21
+ USING (tenant_id = current_setting('coreloom.tenant_id', true))
22
+ WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
23
+ `;
24
+
25
+ /** Every tenant table of this module, with the policy each one must carry. */
26
+ export const SYSTEM_TENANT_TABLES = ['system_module_activations'] as const;
27
+
28
+ export const databaseMigrations: readonly DatabaseMigration[] = [
29
+ {
30
+ id: '0001_system_module_activations',
31
+ sql: { postgresql: SYSTEM_MIGRATION_001 },
32
+ inspectExisting: (database) =>
33
+ postgresTenantTableState(
34
+ database,
35
+ 'system_module_activations',
36
+ 'system_module_activations_tenant_policy',
37
+ ),
38
+ },
39
+ ];