Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,8 +525,12 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow

### 6.2 Multi-Tenancy

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

### 6.3 Observability
Expand Down
199 changes: 199 additions & 0 deletions packages/spec/src/contracts/app-lifecycle-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { IAppLifecycleService } from './app-lifecycle-service';
import type { AppManifest, AppCompatibilityCheck, AppInstallResult } from '../system/app-install.zod';

describe('App Lifecycle Service Contract', () => {
const sampleManifest: AppManifest = {
name: 'crm_basic',
label: 'Basic CRM',
version: '1.0.0',
description: 'A basic CRM app',
objects: ['contact', 'deal'],
views: ['contact_list', 'deal_board'],
flows: ['new_deal_notification'],
hasSeedData: true,
dependencies: [],
};

it('should allow a minimal IAppLifecycleService implementation with all required methods', () => {
const service: IAppLifecycleService = {
checkCompatibility: async () => ({ compatible: true, issues: [] }),
installApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
upgradeApp: async () => ({
success: true,
appId: 'crm_basic',
version: '2.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
uninstallApp: async () => ({ success: true }),
};

expect(typeof service.checkCompatibility).toBe('function');
expect(typeof service.installApp).toBe('function');
expect(typeof service.upgradeApp).toBe('function');
expect(typeof service.uninstallApp).toBe('function');
});

it('should check compatibility before installation', async () => {
const service: IAppLifecycleService = {
checkCompatibility: async (_tenantId, manifest) => {
const issues: AppCompatibilityCheck['issues'] = [];
if (manifest.minKernelVersion && manifest.minKernelVersion > '3.0.0') {
issues.push({
severity: 'error',
message: 'Kernel version too low',
category: 'kernel_version',
});
}
return { compatible: issues.length === 0, issues };
},
installApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
upgradeApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
uninstallApp: async () => ({ success: true }),
};

const result = await service.checkCompatibility('tenant_001', sampleManifest);
expect(result.compatible).toBe(true);
expect(result.issues).toHaveLength(0);

const incompatible = await service.checkCompatibility('tenant_001', {
...sampleManifest,
minKernelVersion: '5.0.0',
});
expect(incompatible.compatible).toBe(false);
expect(incompatible.issues).toHaveLength(1);
expect(incompatible.issues[0].category).toBe('kernel_version');
});

it('should install an app into a tenant', async () => {
const installedApps = new Map<string, AppInstallResult>();

const service: IAppLifecycleService = {
checkCompatibility: async () => ({ compatible: true, issues: [] }),
installApp: async (_tenantId, manifest) => {
const result: AppInstallResult = {
success: true,
appId: manifest.name,
version: manifest.version,
installedObjects: manifest.objects,
createdTables: manifest.objects.map(o => `app_${o}`),
seededRecords: manifest.hasSeedData ? 50 : 0,
durationMs: 2300,
};
installedApps.set(manifest.name, result);
return result;
},
upgradeApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
uninstallApp: async () => ({ success: true }),
};

const result = await service.installApp('tenant_001', sampleManifest);
expect(result.success).toBe(true);
expect(result.appId).toBe('crm_basic');
expect(result.installedObjects).toEqual(['contact', 'deal']);
expect(result.createdTables).toEqual(['app_contact', 'app_deal']);
expect(result.seededRecords).toBe(50);
});

it('should upgrade an installed app', async () => {
const service: IAppLifecycleService = {
checkCompatibility: async () => ({ compatible: true, issues: [] }),
installApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
upgradeApp: async (_tenantId, manifest) => ({
success: true,
appId: manifest.name,
version: manifest.version,
installedObjects: manifest.objects,
createdTables: [],
seededRecords: 0,
durationMs: 1100,
}),
uninstallApp: async () => ({ success: true }),
};

const upgradeManifest: AppManifest = {
...sampleManifest,
version: '2.0.0',
objects: ['contact', 'deal', 'activity'],
};

const result = await service.upgradeApp('tenant_001', upgradeManifest);
expect(result.success).toBe(true);
expect(result.version).toBe('2.0.0');
expect(result.installedObjects).toContain('activity');
});

it('should uninstall an app', async () => {
const apps = new Set(['crm_basic']);

const service: IAppLifecycleService = {
checkCompatibility: async () => ({ compatible: true, issues: [] }),
installApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
upgradeApp: async () => ({
success: true,
appId: 'crm_basic',
version: '1.0.0',
installedObjects: [],
createdTables: [],
seededRecords: 0,
}),
uninstallApp: async (_tenantId, appId) => {
const existed = apps.delete(appId);
return { success: existed };
},
};

const result = await service.uninstallApp('tenant_001', 'crm_basic');
expect(result.success).toBe(true);
expect(apps.has('crm_basic')).toBe(false);

const notFound = await service.uninstallApp('tenant_001', 'nonexistent');
expect(notFound.success).toBe(false);
});
});
127 changes: 127 additions & 0 deletions packages/spec/src/contracts/deploy-pipeline-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { IDeployPipelineService, DeployExecutionResult } from './deploy-pipeline-service';
import type { DeployBundle, MigrationPlan, DeployValidationResult } from '../system/deploy-bundle.zod';

describe('Deploy Pipeline Service Contract', () => {
const sampleBundle: DeployBundle = {
manifest: {
version: '1.0.0',
objects: ['project_task'],
views: [],
flows: [],
permissions: [],
},
objects: [{ name: 'project_task', fields: {} }],
views: [],
flows: [],
permissions: [],
seedData: [],
};

const samplePlan: MigrationPlan = {
statements: [
{ sql: 'CREATE TABLE project_task (id TEXT PRIMARY KEY)', reversible: true, order: 0 },
],
dialect: 'sqlite',
reversible: true,
};

it('should allow a minimal IDeployPipelineService implementation with all required methods', () => {
const service: IDeployPipelineService = {
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
executeDeployment: async () => ({
deploymentId: 'deploy_001',
status: 'ready',
durationMs: 1200,
statementsExecuted: 1,
completedAt: new Date().toISOString(),
}),
rollbackDeployment: async () => {},
};

expect(typeof service.validateBundle).toBe('function');
expect(typeof service.planDeployment).toBe('function');
expect(typeof service.executeDeployment).toBe('function');
expect(typeof service.rollbackDeployment).toBe('function');
});

it('should validate a deploy bundle', () => {
const service: IDeployPipelineService = {
validateBundle: (bundle) => ({
valid: bundle.manifest.version !== '',
issues: [],
errorCount: 0,
warningCount: 0,
}),
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
executeDeployment: async () => ({
deploymentId: 'deploy_001',
status: 'ready',
durationMs: 0,
statementsExecuted: 0,
completedAt: new Date().toISOString(),
}),
rollbackDeployment: async () => {},
};

const result: DeployValidationResult = service.validateBundle(sampleBundle);
expect(result.valid).toBe(true);
expect(result.errorCount).toBe(0);
});

it('should plan and execute a deployment', async () => {
const deployments = new Map<string, DeployExecutionResult>();
let counter = 0;

const service: IDeployPipelineService = {
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
planDeployment: async () => samplePlan,
executeDeployment: async (_tenantId, plan) => {
const result: DeployExecutionResult = {
deploymentId: `deploy_${++counter}`,
status: 'ready',
durationMs: 1500,
statementsExecuted: plan.statements.length,
completedAt: new Date().toISOString(),
};
deployments.set(result.deploymentId, result);
return result;
},
rollbackDeployment: async () => {},
};

const plan = await service.planDeployment('tenant_001', sampleBundle);
expect(plan.statements).toHaveLength(1);
expect(plan.dialect).toBe('sqlite');

const result = await service.executeDeployment('tenant_001', plan);
expect(result.deploymentId).toBe('deploy_1');
expect(result.status).toBe('ready');
expect(result.statementsExecuted).toBe(1);
});

it('should handle rollback', async () => {
let rolledBack = false;

const service: IDeployPipelineService = {
validateBundle: () => ({ valid: true, issues: [], errorCount: 0, warningCount: 0 }),
planDeployment: async () => ({ statements: [], dialect: 'sqlite', reversible: true }),
executeDeployment: async () => ({
deploymentId: 'deploy_001',
status: 'ready',
durationMs: 0,
statementsExecuted: 0,
completedAt: new Date().toISOString(),
}),
rollbackDeployment: async () => {
rolledBack = true;
},
};

await service.rollbackDeployment('tenant_001', 'deploy_001');
expect(rolledBack).toBe(true);
});
});
Loading