@flowdular/sdk 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.ai/platform-capabilities.md +2 -0
  2. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  4. package/.ai/references/catalog/module.json +11 -1
  5. package/.ai/references/catalog/package.json +2 -2
  6. package/.ai/references/catalog/spec/module.yaml +25 -4
  7. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  8. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  9. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  10. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  11. package/.ai/references/catalog/src/client/api.ts +124 -36
  12. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  13. package/.ai/references/catalog/src/client/state.ts +169 -3
  14. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  15. package/.ai/references/catalog/src/domain/types.ts +20 -0
  16. package/.ai/references/catalog/src/platform.ts +20 -0
  17. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  18. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  19. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  20. package/.ai/references/catalog/src/services/migration.ts +27 -1
  21. package/.ai/references/catalog/src/services/repository.ts +31 -2
  22. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  23. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  24. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  25. package/.ai/references/catalog/tests/export.test.ts +134 -0
  26. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  27. package/.ai/references/catalog/tests/list.test.ts +217 -0
  28. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  29. package/.ai/references/catalog/tests/module.test.ts +2 -1
  30. package/.ai/references/catalog/tests/support/database.ts +14 -0
  31. package/.ai/references/catalog/translations/en.json +35 -4
  32. package/.ai/references/catalog/translations/pl.json +35 -4
  33. package/.ai/references/catalog.provenance.json +34 -26
  34. package/README.md +1 -1
  35. package/assets/flowdular-banner.webp +0 -0
  36. package/modules/access/module.json +1 -1
  37. package/modules/access/spec/module.yaml +1 -1
  38. package/modules/agents/module.json +1 -1
  39. package/modules/agents/spec/module.yaml +1 -1
  40. package/modules/approvals/module.json +1 -1
  41. package/modules/approvals/spec/module.yaml +1 -1
  42. package/modules/audit/module.json +1 -1
  43. package/modules/audit/spec/module.yaml +1 -1
  44. package/modules/auth/module.json +2 -2
  45. package/modules/auth/package.json +1 -1
  46. package/modules/auth/spec/module.yaml +2 -2
  47. package/modules/auth/src/middleware/authentication.ts +5 -1
  48. package/modules/auth/src/services/auth-service.ts +26 -0
  49. package/modules/auth/src/services/database-repository.ts +9 -0
  50. package/modules/auth/src/services/repository.ts +2 -0
  51. package/modules/automations/module.json +1 -1
  52. package/modules/automations/spec/module.yaml +1 -1
  53. package/modules/connectors/module.json +1 -1
  54. package/modules/connectors/spec/module.yaml +1 -1
  55. package/modules/directory/module.json +1 -1
  56. package/modules/directory/spec/module.yaml +1 -1
  57. package/modules/documents/module.json +1 -1
  58. package/modules/documents/spec/module.yaml +1 -1
  59. package/modules/exports/module.json +1 -1
  60. package/modules/exports/spec/module.yaml +1 -1
  61. package/modules/import/module.json +1 -1
  62. package/modules/import/spec/module.yaml +1 -1
  63. package/modules/metering/module.json +1 -1
  64. package/modules/metering/spec/module.yaml +1 -1
  65. package/modules/notifications/module.json +1 -1
  66. package/modules/notifications/spec/module.yaml +1 -1
  67. package/modules/reports/module.json +1 -1
  68. package/modules/reports/spec/module.yaml +1 -1
  69. package/modules/sandbox/module.json +1 -1
  70. package/modules/sandbox/spec/module.yaml +1 -1
  71. package/modules/search/module.json +1 -1
  72. package/modules/search/spec/module.yaml +1 -1
  73. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  74. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  75. package/modules/system/module.json +3 -2
  76. package/modules/system/package.json +2 -2
  77. package/modules/system/spec/module.yaml +98 -1
  78. package/modules/system/src/client/ModulesView.tsrx +101 -8
  79. package/modules/system/src/client/api.ts +52 -11
  80. package/modules/system/src/client/module-columns.tsrx +32 -6
  81. package/modules/system/src/client/state.ts +26 -0
  82. package/modules/system/src/domain/modules.ts +93 -0
  83. package/modules/system/src/index.ts +13 -0
  84. package/modules/system/src/platform.ts +41 -1
  85. package/modules/system/src/server/capability.ts +6 -0
  86. package/modules/system/src/server/endpoints.ts +125 -5
  87. package/modules/system/src/server/index.ts +26 -0
  88. package/modules/system/src/server/module-catalog.ts +19 -0
  89. package/modules/system/src/server/runtime.ts +102 -0
  90. package/modules/system/src/services/database-repository.ts +91 -0
  91. package/modules/system/src/services/migration.ts +39 -0
  92. package/modules/system/src/services/module-activation-service.ts +245 -0
  93. package/modules/system/src/services/repository.ts +17 -0
  94. package/modules/system/translations/en.json +17 -1
  95. package/modules/system/translations/pl.json +17 -1
  96. package/modules/users/module.json +2 -2
  97. package/modules/users/package.json +1 -1
  98. package/modules/users/spec/module.yaml +6 -9
  99. package/modules/users/src/client/UsersView.tsrx +27 -13
  100. package/modules/users/src/client/member-columns.tsrx +13 -4
  101. package/modules/users/src/client/state.ts +16 -0
  102. package/modules/users/src/services/users-service.ts +3 -0
  103. package/modules/users/translations/en.json +2 -1
  104. package/modules/users/translations/pl.json +2 -1
  105. package/modules/workflows/module.json +2 -2
  106. package/modules/workflows/package.json +1 -1
  107. package/modules/workflows/spec/module.yaml +2 -2
  108. package/modules/workflows/src/services/database-repository.ts +93 -53
  109. package/package.json +1 -1
  110. package/packages/client/package.json +1 -0
  111. package/packages/client/src/ApplicationShell.tsrx +46 -5
  112. package/packages/client/src/index.ts +5 -0
  113. package/packages/client/src/shell/modules.ts +49 -0
  114. package/packages/client/src/shell/types.ts +3 -0
  115. package/packages/contracts/src/index.ts +11 -1
  116. package/packages/server/src/endpoint.ts +17 -1
  117. package/packages/server/src/index.ts +10 -0
  118. package/packages/server/src/module-activation.ts +69 -0
  119. package/assets/flowdular-banner.png +0 -0
@@ -2,6 +2,22 @@ import { Tag, type TableColumn } from '@flowdular/sdk/ui';
2
2
  import { activeLocale, t } from '@flowdular/sdk/client';
3
3
  import type { ModuleCatalogEntry } from './api.ts';
4
4
 
5
+ export function activationTone(
6
+ module: ModuleCatalogEntry,
7
+ ): 'success' | 'warning' | 'ink' | 'neutral' {
8
+ if (!module.enabled) return 'neutral';
9
+ if (!module.optional) return 'ink';
10
+ return module.active ? 'success' : 'warning';
11
+ }
12
+
13
+ export function activationLabel(module: ModuleCatalogEntry): string {
14
+ if (!module.enabled) return t('system.status.available');
15
+ if (!module.optional) return t('system.status.required');
16
+ return module.active
17
+ ? t('system.status.active')
18
+ : t('system.status.inactive');
19
+ }
20
+
5
21
  export function surfaces(module: ModuleCatalogEntry): string {
6
22
  const parts = [
7
23
  module.platform.server && 'server',
@@ -37,7 +53,7 @@ export function moduleColumns(): readonly TableColumn<ModuleCatalogEntry>[] {
37
53
  {
38
54
  key: 'platform',
39
55
  header: t('system.table.platform'),
40
- width: '30%',
56
+ width: '20%',
41
57
  cell: (module) => <span class="ui-cell">
42
58
  <span>{surfaces(module)}</span>
43
59
  <small>{module.capabilities.join(', ')}</small>
@@ -52,15 +68,25 @@ export function moduleColumns(): readonly TableColumn<ModuleCatalogEntry>[] {
52
68
  module.permissions.length,
53
69
  ),
54
70
  },
71
+ {
72
+ key: 'dependents',
73
+ header: t('system.table.dependents'),
74
+ width: '14%',
75
+ cell: (module) => <span class="ui-cell">
76
+ <span>{new Intl.NumberFormat(activeLocale()).format(
77
+ module.dependents.length,
78
+ )}</span>
79
+ {module.dependents.length > 0
80
+ ? <small class="ui-mono">{module.dependents.join(', ')}</small>
81
+ : null}
82
+ </span>,
83
+ },
55
84
  {
56
85
  key: 'status',
57
86
  header: t('system.table.status'),
58
87
  width: '14%',
59
- cell: (module) => <Tag tone={module.enabled ? 'success' : 'neutral'} dot>
60
- {module.enabled
61
- ? t('system.status.enabled')
62
- : t('system.status.available')}
63
- </Tag>,
88
+ cell: (module) =>
89
+ <Tag tone={activationTone(module)} dot>{activationLabel(module)}</Tag>,
64
90
  },
65
91
  ];
66
92
  }
@@ -4,6 +4,12 @@ import type { ModuleCatalogEntry } from './api.ts';
4
4
  export type ModulesStatus = 'loading' | 'idle' | 'denied' | 'error';
5
5
  export type ModulesTab = 'catalog' | 'flags';
6
6
 
7
+ /** The activation change waiting for the owner's confirmation. */
8
+ export interface PendingActivation {
9
+ readonly moduleId: string;
10
+ readonly active: boolean;
11
+ }
12
+
7
13
  export function createModulesClientState() {
8
14
  const store = createStore({
9
15
  modules: cell<readonly ModuleCatalogEntry[]>([]),
@@ -13,6 +19,26 @@ export function createModulesClientState() {
13
19
  query: '',
14
20
  selectedModuleId: cell<string | null>(null),
15
21
  tab: cell<ModulesTab>('catalog'),
22
+ pending: cell<PendingActivation | null>(null),
23
+ busyModuleId: cell<string | null>(null),
24
+ activationError: '',
16
25
  });
17
26
  return { store, state: store.state };
18
27
  }
28
+
29
+ /** Why the row action is refused before the server is asked, else null. */
30
+ export function activationRefusal(
31
+ module: ModuleCatalogEntry,
32
+ canManage: boolean,
33
+ translate: (key: string, params?: Record<string, string>) => string,
34
+ ): string | null {
35
+ if (!module.enabled) return translate('system.activation.notComposed');
36
+ if (!canManage) return translate('system.activation.deniedManage');
37
+ if (!module.optional) return translate('system.activation.required');
38
+ if (module.active && module.dependents.length > 0) {
39
+ return translate('system.activation.dependents', {
40
+ modules: module.dependents.join(', '),
41
+ });
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,93 @@
1
+ import { REQUIRED_MODULE_IDS } from '@flowdular/sdk/contracts';
2
+
3
+ export const SYSTEM_MODULES_CAPABILITY = 'system.modules.v1';
4
+
5
+ const REQUIRED = new Set<string>(REQUIRED_MODULE_IDS);
6
+
7
+ export function isRequiredModule(moduleId: string): boolean {
8
+ return REQUIRED.has(moduleId);
9
+ }
10
+
11
+ /** One module the application composes, with the modules it cannot run without. */
12
+ export interface ComposedModule {
13
+ readonly id: string;
14
+ readonly version: string;
15
+ readonly dependencies: readonly string[];
16
+ }
17
+
18
+ export interface ModuleActivationEntry {
19
+ readonly id: string;
20
+ readonly version: string;
21
+ readonly active: boolean;
22
+ /** False for a required module, which is never deactivated. */
23
+ readonly optional: boolean;
24
+ /** Composed modules that declare this one as a dependency. */
25
+ readonly dependents: readonly string[];
26
+ }
27
+
28
+ export interface ModuleActivationChange {
29
+ readonly moduleId: string;
30
+ readonly active: boolean;
31
+ }
32
+
33
+ /** What other composed modules a public capability is served by. */
34
+ export interface ManifestDependencies {
35
+ readonly id: string;
36
+ readonly version: string;
37
+ readonly dependencies: readonly string[];
38
+ readonly provides: readonly string[];
39
+ /** Capability ids the module cannot compose without. */
40
+ readonly requires: readonly string[];
41
+ }
42
+
43
+ /* A dependency is a declared module dependency or a required capability,
44
+ resolved to the composed module that provides it. */
45
+ export function composedModules(
46
+ manifests: readonly ManifestDependencies[],
47
+ ): readonly ComposedModule[] {
48
+ const providers = new Map<string, string>();
49
+ const ids = new Set(manifests.map((manifest) => manifest.id));
50
+ for (const manifest of manifests) {
51
+ for (const capability of manifest.provides) {
52
+ providers.set(capability, manifest.id);
53
+ }
54
+ }
55
+ return manifests.map((manifest) => {
56
+ const dependencies = new Set<string>();
57
+ for (const id of manifest.dependencies) {
58
+ if (ids.has(id) && id !== manifest.id) dependencies.add(id);
59
+ }
60
+ for (const capability of manifest.requires) {
61
+ const provider = providers.get(capability);
62
+ if (provider !== undefined && provider !== manifest.id) {
63
+ dependencies.add(provider);
64
+ }
65
+ }
66
+ return {
67
+ id: manifest.id,
68
+ version: manifest.version,
69
+ dependencies: [...dependencies].sort(),
70
+ };
71
+ });
72
+ }
73
+
74
+ export function dependentsOf(
75
+ modules: readonly ComposedModule[],
76
+ moduleId: string,
77
+ ): readonly string[] {
78
+ return modules
79
+ .filter((module) => module.dependencies.includes(moduleId))
80
+ .map((module) => module.id)
81
+ .sort();
82
+ }
83
+
84
+ /** The composed module ids that are active for a workspace, sorted. */
85
+ export function activeModuleIds(
86
+ modules: readonly ComposedModule[],
87
+ inactive: ReadonlySet<string>,
88
+ ): readonly string[] {
89
+ return modules
90
+ .map((module) => module.id)
91
+ .filter((id) => isRequiredModule(id) || !inactive.has(id))
92
+ .sort();
93
+ }
@@ -21,3 +21,16 @@ export {
21
21
  TENANT_TIME_ZONE_KEY,
22
22
  tenantTimeZone,
23
23
  } from './domain/time-zone.ts';
24
+ export {
25
+ activeModuleIds,
26
+ composedModules,
27
+ dependentsOf,
28
+ isRequiredModule,
29
+ SYSTEM_MODULES_CAPABILITY,
30
+ } from './domain/modules.ts';
31
+ export type {
32
+ ComposedModule,
33
+ ManifestDependencies,
34
+ ModuleActivationChange,
35
+ ModuleActivationEntry,
36
+ } from './domain/modules.ts';
@@ -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
+ }