Skip to content

Commit 41791d5

Browse files
authored
Merge pull request #798 from objectstack-ai/copilot/upgrade-tenant-runtime-context
2 parents 713445f + a769e38 commit 41791d5

13 files changed

Lines changed: 635 additions & 18 deletions

ROADMAP.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,12 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
525525

526526
### 6.2 Multi-Tenancy
527527

528-
- [ ] Tenant isolation strategies (schema-per-tenant, row-level, database-per-tenant)
529-
- [ ] Tenant provisioning and lifecycle management
528+
- [x] Tenant isolation strategies (schema-per-tenant, row-level, database-per-tenant) — `system/tenant.zod.ts`: `TenantIsolationConfigSchema` with `RowLevelIsolationStrategySchema`, `SchemaLevelIsolationStrategySchema`, `DatabaseLevelIsolationStrategySchema`
529+
- [x] Tenant provisioning and lifecycle management — `system/provisioning.zod.ts`: `TenantProvisioningRequestSchema`, `TenantProvisioningResultSchema`, `ProvisioningStepSchema`; `contracts/provisioning-service.ts`: `IProvisioningService`
530+
- [x] Tenant runtime context and quota enforcement — `kernel/context.zod.ts`: `TenantRuntimeContextSchema` with `tenantQuotas`; `system/tenant.zod.ts`: `TenantQuotaSchema`, `TenantUsageSchema`, `QuotaEnforcementResultSchema`
531+
- [x] Tenant routing contract — `contracts/tenant-router.ts`: `ITenantRouter` (session → tenantId → DB client)
532+
- [x] Metadata-driven deploy pipeline — `system/deploy-bundle.zod.ts`: `DeployBundleSchema`, `MigrationPlanSchema`, `DeployDiffSchema`; `contracts/deploy-pipeline-service.ts`: `IDeployPipelineService`
533+
- [x] App marketplace installation protocol — `system/app-install.zod.ts`: `AppManifestSchema`, `AppInstallResultSchema`, `AppCompatibilityCheckSchema`; `contracts/app-lifecycle-service.ts`: `IAppLifecycleService`
530534
- [ ] Cross-tenant data sharing policies
531535

532536
### 6.3 Observability
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import type { IAppLifecycleService } from './app-lifecycle-service';
5+
import type { AppManifest, AppCompatibilityCheck, AppInstallResult } from '../system/app-install.zod';
6+
7+
describe('App Lifecycle Service Contract', () => {
8+
const sampleManifest: AppManifest = {
9+
name: 'crm_basic',
10+
label: 'Basic CRM',
11+
version: '1.0.0',
12+
description: 'A basic CRM app',
13+
objects: ['contact', 'deal'],
14+
views: ['contact_list', 'deal_board'],
15+
flows: ['new_deal_notification'],
16+
hasSeedData: true,
17+
dependencies: [],
18+
};
19+
20+
it('should allow a minimal IAppLifecycleService implementation with all required methods', () => {
21+
const service: IAppLifecycleService = {
22+
checkCompatibility: async () => ({ compatible: true, issues: [] }),
23+
installApp: async () => ({
24+
success: true,
25+
appId: 'crm_basic',
26+
version: '1.0.0',
27+
installedObjects: [],
28+
createdTables: [],
29+
seededRecords: 0,
30+
}),
31+
upgradeApp: async () => ({
32+
success: true,
33+
appId: 'crm_basic',
34+
version: '2.0.0',
35+
installedObjects: [],
36+
createdTables: [],
37+
seededRecords: 0,
38+
}),
39+
uninstallApp: async () => ({ success: true }),
40+
};
41+
42+
expect(typeof service.checkCompatibility).toBe('function');
43+
expect(typeof service.installApp).toBe('function');
44+
expect(typeof service.upgradeApp).toBe('function');
45+
expect(typeof service.uninstallApp).toBe('function');
46+
});
47+
48+
it('should check compatibility before installation', async () => {
49+
const service: IAppLifecycleService = {
50+
checkCompatibility: async (_tenantId, manifest) => {
51+
const issues: AppCompatibilityCheck['issues'] = [];
52+
if (manifest.minKernelVersion && manifest.minKernelVersion > '3.0.0') {
53+
issues.push({
54+
severity: 'error',
55+
message: 'Kernel version too low',
56+
category: 'kernel_version',
57+
});
58+
}
59+
return { compatible: issues.length === 0, issues };
60+
},
61+
installApp: async () => ({
62+
success: true,
63+
appId: 'crm_basic',
64+
version: '1.0.0',
65+
installedObjects: [],
66+
createdTables: [],
67+
seededRecords: 0,
68+
}),
69+
upgradeApp: async () => ({
70+
success: true,
71+
appId: 'crm_basic',
72+
version: '1.0.0',
73+
installedObjects: [],
74+
createdTables: [],
75+
seededRecords: 0,
76+
}),
77+
uninstallApp: async () => ({ success: true }),
78+
};
79+
80+
const result = await service.checkCompatibility('tenant_001', sampleManifest);
81+
expect(result.compatible).toBe(true);
82+
expect(result.issues).toHaveLength(0);
83+
84+
const incompatible = await service.checkCompatibility('tenant_001', {
85+
...sampleManifest,
86+
minKernelVersion: '5.0.0',
87+
});
88+
expect(incompatible.compatible).toBe(false);
89+
expect(incompatible.issues).toHaveLength(1);
90+
expect(incompatible.issues[0].category).toBe('kernel_version');
91+
});
92+
93+
it('should install an app into a tenant', async () => {
94+
const installedApps = new Map<string, AppInstallResult>();
95+
96+
const service: IAppLifecycleService = {
97+
checkCompatibility: async () => ({ compatible: true, issues: [] }),
98+
installApp: async (_tenantId, manifest) => {
99+
const result: AppInstallResult = {
100+
success: true,
101+
appId: manifest.name,
102+
version: manifest.version,
103+
installedObjects: manifest.objects,
104+
createdTables: manifest.objects.map(o => `app_${o}`),
105+
seededRecords: manifest.hasSeedData ? 50 : 0,
106+
durationMs: 2300,
107+
};
108+
installedApps.set(manifest.name, result);
109+
return result;
110+
},
111+
upgradeApp: async () => ({
112+
success: true,
113+
appId: 'crm_basic',
114+
version: '1.0.0',
115+
installedObjects: [],
116+
createdTables: [],
117+
seededRecords: 0,
118+
}),
119+
uninstallApp: async () => ({ success: true }),
120+
};
121+
122+
const result = await service.installApp('tenant_001', sampleManifest);
123+
expect(result.success).toBe(true);
124+
expect(result.appId).toBe('crm_basic');
125+
expect(result.installedObjects).toEqual(['contact', 'deal']);
126+
expect(result.createdTables).toEqual(['app_contact', 'app_deal']);
127+
expect(result.seededRecords).toBe(50);
128+
});
129+
130+
it('should upgrade an installed app', async () => {
131+
const service: IAppLifecycleService = {
132+
checkCompatibility: async () => ({ compatible: true, issues: [] }),
133+
installApp: async () => ({
134+
success: true,
135+
appId: 'crm_basic',
136+
version: '1.0.0',
137+
installedObjects: [],
138+
createdTables: [],
139+
seededRecords: 0,
140+
}),
141+
upgradeApp: async (_tenantId, manifest) => ({
142+
success: true,
143+
appId: manifest.name,
144+
version: manifest.version,
145+
installedObjects: manifest.objects,
146+
createdTables: [],
147+
seededRecords: 0,
148+
durationMs: 1100,
149+
}),
150+
uninstallApp: async () => ({ success: true }),
151+
};
152+
153+
const upgradeManifest: AppManifest = {
154+
...sampleManifest,
155+
version: '2.0.0',
156+
objects: ['contact', 'deal', 'activity'],
157+
};
158+
159+
const result = await service.upgradeApp('tenant_001', upgradeManifest);
160+
expect(result.success).toBe(true);
161+
expect(result.version).toBe('2.0.0');
162+
expect(result.installedObjects).toContain('activity');
163+
});
164+
165+
it('should uninstall an app', async () => {
166+
const apps = new Set(['crm_basic']);
167+
168+
const service: IAppLifecycleService = {
169+
checkCompatibility: async () => ({ compatible: true, issues: [] }),
170+
installApp: async () => ({
171+
success: true,
172+
appId: 'crm_basic',
173+
version: '1.0.0',
174+
installedObjects: [],
175+
createdTables: [],
176+
seededRecords: 0,
177+
}),
178+
upgradeApp: async () => ({
179+
success: true,
180+
appId: 'crm_basic',
181+
version: '1.0.0',
182+
installedObjects: [],
183+
createdTables: [],
184+
seededRecords: 0,
185+
}),
186+
uninstallApp: async (_tenantId, appId) => {
187+
const existed = apps.delete(appId);
188+
return { success: existed };
189+
},
190+
};
191+
192+
const result = await service.uninstallApp('tenant_001', 'crm_basic');
193+
expect(result.success).toBe(true);
194+
expect(apps.has('crm_basic')).toBe(false);
195+
196+
const notFound = await service.uninstallApp('tenant_001', 'nonexistent');
197+
expect(notFound.success).toBe(false);
198+
});
199+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import type { IDeployPipelineService, DeployExecutionResult } from './deploy-pipeline-service';
5+
import type { DeployBundle, MigrationPlan, DeployValidationResult } from '../system/deploy-bundle.zod';
6+
7+
describe('Deploy Pipeline Service Contract', () => {
8+
const sampleBundle: DeployBundle = {
9+
manifest: {
10+
version: '1.0.0',
11+
objects: ['project_task'],
12+
views: [],
13+
flows: [],
14+
permissions: [],
15+
},
16+
objects: [{ name: 'project_task', fields: {} }],
17+
views: [],
18+
flows: [],
19+
permissions: [],
20+
seedData: [],
21+
};
22+
23+
const samplePlan: MigrationPlan = {
24+
statements: [
25+
{ sql: 'CREATE TABLE project_task (id TEXT PRIMARY KEY)', reversible: true, order: 0 },
26+
],
27+
dialect: 'sqlite',
28+
reversible: true,
29+
};
30+
31+
it('should allow a minimal IDeployPipelineService implementation with all required methods', () => {
32+
const service: IDeployPipelineService = {
33+
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
34+
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
35+
executeDeployment: async () => ({
36+
deploymentId: 'deploy_001',
37+
status: 'ready',
38+
durationMs: 1200,
39+
statementsExecuted: 1,
40+
completedAt: new Date().toISOString(),
41+
}),
42+
rollbackDeployment: async () => {},
43+
};
44+
45+
expect(typeof service.validateBundle).toBe('function');
46+
expect(typeof service.planDeployment).toBe('function');
47+
expect(typeof service.executeDeployment).toBe('function');
48+
expect(typeof service.rollbackDeployment).toBe('function');
49+
});
50+
51+
it('should validate a deploy bundle', () => {
52+
const service: IDeployPipelineService = {
53+
validateBundle: (bundle) => ({
54+
valid: bundle.manifest.version !== '',
55+
issues: [],
56+
errorCount: 0,
57+
warningCount: 0,
58+
}),
59+
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
60+
executeDeployment: async () => ({
61+
deploymentId: 'deploy_001',
62+
status: 'ready',
63+
durationMs: 0,
64+
statementsExecuted: 0,
65+
completedAt: new Date().toISOString(),
66+
}),
67+
rollbackDeployment: async () => {},
68+
};
69+
70+
const result: DeployValidationResult = service.validateBundle(sampleBundle);
71+
expect(result.valid).toBe(true);
72+
expect(result.errorCount).toBe(0);
73+
});
74+
75+
it('should plan and execute a deployment', async () => {
76+
const deployments = new Map<string, DeployExecutionResult>();
77+
let counter = 0;
78+
79+
const service: IDeployPipelineService = {
80+
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
81+
planDeployment: async () => samplePlan,
82+
executeDeployment: async (_tenantId, plan) => {
83+
const result: DeployExecutionResult = {
84+
deploymentId: `deploy_${++counter}`,
85+
status: 'ready',
86+
durationMs: 1500,
87+
statementsExecuted: plan.statements.length,
88+
completedAt: new Date().toISOString(),
89+
};
90+
deployments.set(result.deploymentId, result);
91+
return result;
92+
},
93+
rollbackDeployment: async () => {},
94+
};
95+
96+
const plan = await service.planDeployment('tenant_001', sampleBundle);
97+
expect(plan.statements).toHaveLength(1);
98+
expect(plan.dialect).toBe('sqlite');
99+
100+
const result = await service.executeDeployment('tenant_001', plan);
101+
expect(result.deploymentId).toBe('deploy_1');
102+
expect(result.status).toBe('ready');
103+
expect(result.statementsExecuted).toBe(1);
104+
});
105+
106+
it('should handle rollback', async () => {
107+
let rolledBack = false;
108+
109+
const service: IDeployPipelineService = {
110+
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
111+
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
112+
executeDeployment: async () => ({
113+
deploymentId: 'deploy_001',
114+
status: 'ready',
115+
durationMs: 0,
116+
statementsExecuted: 0,
117+
completedAt: new Date().toISOString(),
118+
}),
119+
rollbackDeployment: async () => {
120+
rolledBack = true;
121+
},
122+
};
123+
124+
await service.rollbackDeployment('tenant_001', 'deploy_001');
125+
expect(rolledBack).toBe(true);
126+
});
127+
});

0 commit comments

Comments
 (0)