@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.
- package/.ai/platform-capabilities.md +2 -0
- 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/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/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- 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/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- 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/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- 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: '
|
|
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) =>
|
|
60
|
-
{module
|
|
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 {
|
|
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
|
-
/*
|
|
240
|
-
|
|
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
|
+
}
|