@abloatai/transaction 0.58.0 → 0.59.0
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/dist/client/ablo.d.ts +3 -4
- package/dist/client/ablo.d.ts.map +1 -1
- package/dist/client/ablo.js.map +1 -1
- package/dist/client/surface.d.ts +15 -0
- package/dist/client/surface.d.ts.map +1 -0
- package/dist/client/surface.js +29 -0
- package/dist/client/surface.js.map +1 -0
- package/dist/pricing.d.ts +7 -1
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +16 -2
- package/dist/pricing.js.map +1 -1
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +1 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/deployment/backfill.d.ts +44 -0
- package/dist/schema/deployment/backfill.d.ts.map +1 -0
- package/dist/schema/deployment/backfill.js +57 -0
- package/dist/schema/deployment/backfill.js.map +1 -0
- package/dist/schema/deployment/contracts.d.ts +526 -0
- package/dist/schema/deployment/contracts.d.ts.map +1 -0
- package/dist/schema/deployment/contracts.js +77 -0
- package/dist/schema/deployment/contracts.js.map +1 -0
- package/dist/schema/deployment/fingerprint.d.ts +3 -0
- package/dist/schema/deployment/fingerprint.d.ts.map +1 -0
- package/dist/schema/deployment/fingerprint.js +18 -0
- package/dist/schema/deployment/fingerprint.js.map +1 -0
- package/dist/schema/deployment/index.d.ts +18 -0
- package/dist/schema/deployment/index.d.ts.map +1 -0
- package/dist/schema/deployment/index.js +71 -0
- package/dist/schema/deployment/index.js.map +1 -0
- package/dist/schema/deployment/postgresCatalog.d.ts +39 -0
- package/dist/schema/deployment/postgresCatalog.d.ts.map +1 -0
- package/dist/schema/deployment/postgresCatalog.js +86 -0
- package/dist/schema/deployment/postgresCatalog.js.map +1 -0
- package/dist/schema/deployment/reconcile.d.ts +20 -0
- package/dist/schema/deployment/reconcile.d.ts.map +1 -0
- package/dist/schema/deployment/reconcile.js +226 -0
- package/dist/schema/deployment/reconcile.js.map +1 -0
- package/dist/schema/deployment/sequence.d.ts +4 -0
- package/dist/schema/deployment/sequence.d.ts.map +1 -0
- package/dist/schema/deployment/sequence.js +71 -0
- package/dist/schema/deployment/sequence.js.map +1 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -0
- package/dist/schema/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/ablo.ts +2 -2
- package/src/client/surface.ts +50 -0
- package/src/pricing.ts +16 -2
- package/src/schema/ddl.ts +3 -1
- package/src/schema/deployment/backfill.ts +88 -0
- package/src/schema/deployment/contracts.ts +119 -0
- package/src/schema/deployment/fingerprint.ts +13 -0
- package/src/schema/deployment/index.ts +81 -0
- package/src/schema/deployment/postgresCatalog.ts +131 -0
- package/src/schema/deployment/reconcile.ts +219 -0
- package/src/schema/deployment/sequence.ts +85 -0
- package/src/schema/index.ts +5 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { DatabaseTableSnapshot } from './contracts.js';
|
|
2
|
+
|
|
3
|
+
export interface PostgresColumnCatalogRow {
|
|
4
|
+
tableName: string;
|
|
5
|
+
columnName: string;
|
|
6
|
+
dataType: string;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
defaultValue: string | null;
|
|
9
|
+
primary: boolean;
|
|
10
|
+
uniqueColumn: boolean;
|
|
11
|
+
rowLevelSecurity: boolean;
|
|
12
|
+
forceRowLevelSecurity: boolean;
|
|
13
|
+
replicaIdentity: string;
|
|
14
|
+
publicationMember: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PostgresIndexCatalogRow {
|
|
18
|
+
tableName: string;
|
|
19
|
+
indexName: string;
|
|
20
|
+
columns: string[];
|
|
21
|
+
uniqueIndex: boolean;
|
|
22
|
+
valid: boolean;
|
|
23
|
+
ready: boolean;
|
|
24
|
+
predicate: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PostgresForeignKeyCatalogRow {
|
|
28
|
+
tableName: string;
|
|
29
|
+
constraintName: string;
|
|
30
|
+
columns: string[];
|
|
31
|
+
referencedSchema: string;
|
|
32
|
+
referencedTable: string;
|
|
33
|
+
referencedColumns: string[];
|
|
34
|
+
validated: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const POSTGRES_COLUMN_CATALOG_SQL = `
|
|
38
|
+
SELECT c.relname AS "tableName", a.attname AS "columnName",
|
|
39
|
+
format_type(a.atttypid, a.atttypmod) AS "dataType", NOT a.attnotnull AS nullable,
|
|
40
|
+
pg_get_expr(d.adbin, d.adrelid) AS "defaultValue",
|
|
41
|
+
EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p' AND cardinality(k.conkey) = 1 AND a.attnum = ANY(k.conkey)) AS primary,
|
|
42
|
+
EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype IN ('p','u') AND cardinality(k.conkey) = 1 AND a.attnum = ANY(k.conkey)) AS "uniqueColumn",
|
|
43
|
+
c.relrowsecurity AS "rowLevelSecurity", c.relforcerowsecurity AS "forceRowLevelSecurity",
|
|
44
|
+
c.relreplident::text AS "replicaIdentity",
|
|
45
|
+
EXISTS (SELECT 1 FROM pg_publication_tables p WHERE p.schemaname = n.nspname AND p.tablename = c.relname) AS "publicationMember"
|
|
46
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
47
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
48
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
|
|
49
|
+
WHERE n.nspname = $1 AND c.relkind IN ('r','p') ORDER BY c.relname, a.attnum
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
export const POSTGRES_INDEX_CATALOG_SQL = `
|
|
53
|
+
SELECT t.relname AS "tableName", i.relname AS "indexName",
|
|
54
|
+
ARRAY(SELECT a.attname FROM unnest(ix.indkey) WITH ORDINALITY keys(attnum, ord) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = keys.attnum ORDER BY keys.ord) AS columns,
|
|
55
|
+
ix.indisunique AS "uniqueIndex", ix.indisvalid AS valid, ix.indisready AS ready,
|
|
56
|
+
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
|
57
|
+
FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid
|
|
58
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace WHERE n.nspname = $1 ORDER BY t.relname, i.relname
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export const POSTGRES_FOREIGN_KEY_CATALOG_SQL = `
|
|
62
|
+
SELECT t.relname AS "tableName", c.conname AS "constraintName",
|
|
63
|
+
ARRAY(SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY keys(attnum, ord) JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = keys.attnum ORDER BY keys.ord) AS columns,
|
|
64
|
+
rn.nspname AS "referencedSchema", rt.relname AS "referencedTable",
|
|
65
|
+
ARRAY(SELECT a.attname FROM unnest(c.confkey) WITH ORDINALITY keys(attnum, ord) JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = keys.attnum ORDER BY keys.ord) AS "referencedColumns",
|
|
66
|
+
c.convalidated AS validated
|
|
67
|
+
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
68
|
+
JOIN pg_class rt ON rt.oid = c.confrelid JOIN pg_namespace rn ON rn.oid = rt.relnamespace
|
|
69
|
+
WHERE c.contype = 'f' AND n.nspname = $1 ORDER BY t.relname, c.conname
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export function quotePostgresIdentifier(value: string): string {
|
|
73
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function postgresNullCountSql(appSchema: string, table: string, column: string): string {
|
|
77
|
+
const qualified = `${quotePostgresIdentifier(appSchema)}.${quotePostgresIdentifier(table)}`;
|
|
78
|
+
return `SELECT count(*)::int AS n FROM (SELECT 1 FROM ${qualified} WHERE ${quotePostgresIdentifier(column)} IS NULL LIMIT 501) unstamped`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function foldPostgresCatalog(
|
|
82
|
+
appSchema: string,
|
|
83
|
+
columns: readonly PostgresColumnCatalogRow[],
|
|
84
|
+
indexes: readonly PostgresIndexCatalogRow[],
|
|
85
|
+
foreignKeys: readonly PostgresForeignKeyCatalogRow[],
|
|
86
|
+
): Record<string, DatabaseTableSnapshot> {
|
|
87
|
+
const tables: Record<string, DatabaseTableSnapshot> = {};
|
|
88
|
+
for (const row of columns) {
|
|
89
|
+
const table = tables[row.tableName] ?? {
|
|
90
|
+
schema: appSchema,
|
|
91
|
+
name: row.tableName,
|
|
92
|
+
columns: {},
|
|
93
|
+
indexes: [],
|
|
94
|
+
foreignKeys: [],
|
|
95
|
+
rowLevelSecurity: row.rowLevelSecurity,
|
|
96
|
+
forceRowLevelSecurity: row.forceRowLevelSecurity,
|
|
97
|
+
replicaIdentity: row.replicaIdentity,
|
|
98
|
+
publicationMember: row.publicationMember,
|
|
99
|
+
};
|
|
100
|
+
table.columns[row.columnName] = {
|
|
101
|
+
name: row.columnName,
|
|
102
|
+
dataType: row.dataType,
|
|
103
|
+
nullable: row.nullable,
|
|
104
|
+
default: row.defaultValue,
|
|
105
|
+
primary: row.primary,
|
|
106
|
+
unique: row.uniqueColumn,
|
|
107
|
+
};
|
|
108
|
+
tables[row.tableName] = table;
|
|
109
|
+
}
|
|
110
|
+
for (const row of indexes) {
|
|
111
|
+
tables[row.tableName]?.indexes?.push({
|
|
112
|
+
name: row.indexName,
|
|
113
|
+
columns: row.columns,
|
|
114
|
+
unique: row.uniqueIndex,
|
|
115
|
+
valid: row.valid,
|
|
116
|
+
ready: row.ready,
|
|
117
|
+
predicate: row.predicate,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
for (const row of foreignKeys) {
|
|
121
|
+
tables[row.tableName]?.foreignKeys?.push({
|
|
122
|
+
name: row.constraintName,
|
|
123
|
+
columns: row.columns,
|
|
124
|
+
referencedSchema: row.referencedSchema,
|
|
125
|
+
referencedTable: row.referencedTable,
|
|
126
|
+
referencedColumns: row.referencedColumns,
|
|
127
|
+
validated: row.validated,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return tables;
|
|
131
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { auditSchemaAccessPolicies } from '../audit.js';
|
|
2
|
+
import { camelToSnake, sqlType } from '../ddl.js';
|
|
3
|
+
import { classifyMigration, diffSchema, unresolvedBlockers, type BackfillValue, type MigrationSignal, type MigrationStep, type RenameHints } from '../diff.js';
|
|
4
|
+
import type { ModelJSON, SchemaJSON } from '../serialize.js';
|
|
5
|
+
import { resolveTenancy, tenancyColumn } from '../tenancy.js';
|
|
6
|
+
import type { DatabaseSnapshot, DeploymentDirection, DeploymentFinding, DeploymentManifest } from './contracts.js';
|
|
7
|
+
|
|
8
|
+
const id = (...parts: readonly (string | undefined)[]): string => parts.filter(Boolean).join(':');
|
|
9
|
+
|
|
10
|
+
function signalFinding(signal: MigrationSignal, direction: DeploymentDirection): DeploymentFinding {
|
|
11
|
+
const destructive = ['drop_model', 'drop_field', 'risky_cast', 'lossy_recreate', 'enum_value_removed'].includes(signal.code);
|
|
12
|
+
return {
|
|
13
|
+
id: id(direction, signal.code, signal.model, signal.field), code: signal.code,
|
|
14
|
+
category: destructive ? 'destructive_contract' : 'data_movement', severity: destructive ? 'error' : 'blocker',
|
|
15
|
+
direction, phase: destructive ? 'contract' : 'backfill', owner: 'application_migration', model: signal.model,
|
|
16
|
+
...(signal.field ? { field: signal.field } : {}), message: signal.detail,
|
|
17
|
+
action: destructive ? 'Schedule this contract change after compatibility and data verification pass.' : 'Provide and verify a backfill before making this field required.',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stepFinding(step: MigrationStep): DeploymentFinding | null {
|
|
22
|
+
if (step.kind === 'drop_field' || step.kind === 'drop_model') return null;
|
|
23
|
+
if (step.kind === 'create_model') return {
|
|
24
|
+
id: id('source_to_active', step.kind, step.model), code: step.kind, category: 'compatibility', severity: 'info', direction: 'source_to_active',
|
|
25
|
+
phase: 'expand', owner: 'ablo', model: step.model, from: null, to: step.tableName,
|
|
26
|
+
message: `Model "${step.model}" is not active on the target plane.`, action: 'Expand its physical contract, then activate the candidate schema.',
|
|
27
|
+
};
|
|
28
|
+
if (step.kind === 'add_field') return {
|
|
29
|
+
id: id('source_to_active', step.kind, step.model, step.field), code: step.kind, category: 'compatibility', severity: step.meta.isOptional ? 'info' : 'blocker', direction: 'source_to_active',
|
|
30
|
+
phase: 'expand', owner: step.meta.isOptional ? 'ablo' : 'application_migration', model: step.model, field: step.field,
|
|
31
|
+
column: step.meta.column ?? camelToSnake(step.field), from: null, to: step.meta.type,
|
|
32
|
+
message: `Field "${step.model}.${step.field}" is not active on the target plane.`,
|
|
33
|
+
action: step.meta.isOptional ? 'Add the nullable column before activating the candidate schema.' : 'Add the column, backfill existing rows, verify it, then enforce the required contract.',
|
|
34
|
+
};
|
|
35
|
+
if (step.kind === 'rename_model') return {
|
|
36
|
+
id: id('source_to_active', step.kind, step.from, step.to), code: step.kind, category: 'compatibility', severity: 'warning', direction: 'source_to_active',
|
|
37
|
+
phase: 'dual_write', owner: 'application', model: step.to, from: step.from, to: step.to,
|
|
38
|
+
message: `Model "${step.from}" is renamed to "${step.to}".`, action: 'Verify the rename mapping and compatibility window before activation.',
|
|
39
|
+
};
|
|
40
|
+
if (step.kind === 'rename_field') return {
|
|
41
|
+
id: id('source_to_active', step.kind, step.model, step.from, step.to), code: step.kind, category: 'compatibility', severity: 'warning', direction: 'source_to_active',
|
|
42
|
+
phase: 'dual_write', owner: 'application', model: step.model, field: step.to, from: step.from, to: step.to,
|
|
43
|
+
message: `Field "${step.model}.${step.from}" is renamed to "${step.to}".`, action: 'Keep old and new readers/writers compatible through the rename window.',
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
id: id('source_to_active', step.kind, step.model, step.field), code: step.kind, category: 'compatibility', severity: 'warning', direction: 'source_to_active',
|
|
47
|
+
phase: 'dual_write', owner: 'application_migration', model: step.model, field: step.field, from: step.changes,
|
|
48
|
+
message: `Field "${step.model}.${step.field}" changes shape.`, action: 'Apply the ordered physical and compatibility changes, then verify old and current clients.',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reconcileSourceToActive(active: SchemaJSON | null, source: SchemaJSON, hints: RenameHints = {}, backfills: readonly BackfillValue[] = []): readonly DeploymentFinding[] {
|
|
53
|
+
return reconcileSourceToActiveResult(active, source, hints, backfills).findings;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function reconcileSourceToActiveResult(active: SchemaJSON | null, source: SchemaJSON, hints: RenameHints = {}, backfills: readonly BackfillValue[] = []): { operations: readonly MigrationStep[]; findings: readonly DeploymentFinding[] } {
|
|
57
|
+
const steps = diffSchema(active, source, hints);
|
|
58
|
+
const classification = classifyMigration(steps);
|
|
59
|
+
const signals = [...unresolvedBlockers(classification, backfills), ...classification.warnings].map((signal) => signalFinding(signal, 'source_to_active'));
|
|
60
|
+
const signalLocations = new Set(signals.map(({ model, field }) => `${model}:${field ?? ''}`));
|
|
61
|
+
const ordinary = steps
|
|
62
|
+
.map(stepFinding)
|
|
63
|
+
.filter((finding): finding is DeploymentFinding => finding !== null)
|
|
64
|
+
.filter((finding) => !signalLocations.has(`${finding.model}:${finding.field ?? ''}`))
|
|
65
|
+
.map((finding) => {
|
|
66
|
+
const hasBackfill = finding.model !== undefined && finding.field !== undefined &&
|
|
67
|
+
backfills.some((backfill) => backfill.model === finding.model && backfill.field === finding.field);
|
|
68
|
+
return hasBackfill && finding.code === 'add_field' && finding.severity === 'blocker'
|
|
69
|
+
? {
|
|
70
|
+
...finding,
|
|
71
|
+
severity: 'info' as const,
|
|
72
|
+
phase: 'backfill' as const,
|
|
73
|
+
action: 'Run the declared backfill, verify it completed, then enforce the required contract.',
|
|
74
|
+
}
|
|
75
|
+
: finding;
|
|
76
|
+
});
|
|
77
|
+
return { operations: steps, findings: [...signals, ...ordinary] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function reconcilePolicyIntent(source: SchemaJSON): readonly DeploymentFinding[] {
|
|
81
|
+
return auditSchemaAccessPolicies(source).map((finding) => ({
|
|
82
|
+
id: id('policy', finding.code, finding.model), code: finding.code, category: 'policy_intent', severity: 'blocker', direction: 'source_to_active',
|
|
83
|
+
phase: 'intent', owner: 'application', model: finding.model, message: finding.message, action: finding.fix,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizePgType(value: string): string {
|
|
88
|
+
const type = value.toLowerCase();
|
|
89
|
+
if (type.includes('timestamp')) return 'TIMESTAMPTZ';
|
|
90
|
+
if (type === 'double precision' || type === 'numeric' || type === 'real' || type.includes('int')) return 'DOUBLE PRECISION';
|
|
91
|
+
if (type === 'boolean') return 'BOOLEAN';
|
|
92
|
+
if (type === 'json' || type === 'jsonb') return 'JSONB';
|
|
93
|
+
return 'TEXT';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requiredColumns(model: ModelJSON): readonly { field?: string; column: string; type: string; nullable: boolean; primary: boolean }[] {
|
|
97
|
+
const orgColumn = tenancyColumn(resolveTenancy(model));
|
|
98
|
+
return [
|
|
99
|
+
{ column: 'id', type: 'TEXT', nullable: false, primary: true },
|
|
100
|
+
...(orgColumn ? [{ column: orgColumn, type: 'TEXT', nullable: false, primary: false }] : []),
|
|
101
|
+
...Object.entries(model.fields).map(([field, meta]) => ({ field, column: meta.column ?? camelToSnake(field), type: sqlType(meta.type), nullable: meta.isOptional, primary: false })),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function physicalFindings(schema: SchemaJSON, database: DatabaseSnapshot, direction: 'source_to_database' | 'active_to_database'): DeploymentFinding[] {
|
|
106
|
+
const findings: DeploymentFinding[] = [];
|
|
107
|
+
const physicalOwner = database.ownership === 'ablo' ? 'ablo' : 'application_migration';
|
|
108
|
+
for (const [modelKey, model] of Object.entries(schema.models)) {
|
|
109
|
+
if ((model.plane ?? 'tenant') === 'control') continue;
|
|
110
|
+
const tableName = model.tableName ?? modelKey;
|
|
111
|
+
const table = database.tables[tableName];
|
|
112
|
+
if (!table) {
|
|
113
|
+
findings.push({ id: id(direction, 'missing_table', modelKey), code: 'missing_table', category: 'physical_contract', severity: database.ownership === 'ablo' ? 'info' : 'blocker', direction, phase: 'expand', owner: physicalOwner, model: modelKey, from: null, to: tableName, message: `Table "${database.appSchema}.${tableName}" is missing.`, action: database.ownership === 'ablo' ? 'Create it through the reviewed Ablo expand plan.' : 'Create it through the application migration owner, then re-run the plan.' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const expected of requiredColumns(model)) {
|
|
117
|
+
const actual = table.columns[expected.column];
|
|
118
|
+
const base = [direction, modelKey, expected.field, expected.column];
|
|
119
|
+
if (!actual) {
|
|
120
|
+
findings.push({ id: id(...base, 'missing_column'), code: expected.primary ? 'missing_identity_column' : 'missing_column', category: 'physical_contract', severity: database.ownership === 'ablo' ? 'info' : 'blocker', direction, phase: 'expand', owner: physicalOwner, model: modelKey, ...(expected.field ? { field: expected.field } : {}), column: expected.column, from: null, to: expected.type, message: `Column "${tableName}.${expected.column}" is missing.`, action: database.ownership === 'ablo' ? 'Create it through the reviewed Ablo expand plan.' : expected.primary ? 'Add or explicitly map a stable row identity and enforce uniqueness and non-nullness.' : 'Add the column through the application migration owner, then re-run the plan.' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (expected.primary && !actual.primary && !actual.unique) findings.push({ id: id(...base, 'identity_not_unique'), code: 'identity_not_unique', category: 'physical_contract', severity: 'blocker', direction, phase: 'expand', owner: physicalOwner, model: modelKey, column: expected.column, from: { primary: actual.primary, unique: actual.unique }, to: { primary: true }, message: `Column "${tableName}.${expected.column}" is present but is not a usable unique row identity.`, action: 'Verify uniqueness and non-nullness, then add a primary-key or unique constraint.' });
|
|
124
|
+
const actualType = normalizePgType(actual.dataType);
|
|
125
|
+
if (actualType !== expected.type) findings.push({ id: id(...base, 'column_type_mismatch'), code: 'column_type_mismatch', category: 'physical_contract', severity: 'blocker', direction, phase: 'expand', owner: physicalOwner, model: modelKey, ...(expected.field ? { field: expected.field } : {}), column: expected.column, from: actualType, to: expected.type, message: `Column "${tableName}.${expected.column}" is ${actualType}; the schema expects ${expected.type}.`, action: 'Review and apply a safe type migration before activation.' });
|
|
126
|
+
if (!expected.nullable && actual.nullable) findings.push({ id: id(...base, 'column_nullable'), code: 'column_nullable', category: 'data_movement', severity: 'blocker', direction, phase: 'backfill', owner: physicalOwner, model: modelKey, ...(expected.field ? { field: expected.field } : {}), column: expected.column, from: 'nullable', to: 'required', message: `Column "${tableName}.${expected.column}" is nullable; the schema requires a value.`, action: 'Backfill NULL rows, verify none remain, then enforce NOT NULL.' });
|
|
127
|
+
if (expected.column === tenancyColumn(resolveTenancy(model)) && actual.nullCount !== undefined && actual.nullCount !== null && actual.nullCount > 0) {
|
|
128
|
+
findings.push({ id: id(...base, 'unstamped_tenancy_rows'), code: 'unstamped_tenancy_rows', category: 'data_movement', severity: 'blocker', direction, phase: 'backfill', owner: 'application_migration', model: modelKey, column: expected.column, from: actual.nullCount, to: 0, message: `Table "${tableName}" has ${actual.nullCount >= 501 ? '500+' : actual.nullCount} row(s) with no "${expected.column}" routing value.`, action: 'Backfill the routing value, verify zero NULL rows, then run `ablo connect resnapshot`.' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const column of ['created_by', 'created_at', 'updated_at']) if (!table.columns[column]) findings.push({ id: id(direction, modelKey, column, 'base_column_degraded'), code: 'base_column_degraded', category: 'advisory', severity: 'warning', direction, phase: 'intent', owner: 'application', model: modelKey, column, message: `Table "${tableName}" has no "${column}"; audit or ordering metadata may be reduced.`, action: 'Declare and add this field only if the application needs that metadata.' });
|
|
132
|
+
for (const [field, meta] of Object.entries(model.fields)) if (meta.isIndexed) {
|
|
133
|
+
const column = meta.column ?? camelToSnake(field);
|
|
134
|
+
const index = table.indexes?.find((candidate) => candidate.columns.length === 1 && candidate.columns[0] === column && candidate.valid && candidate.ready);
|
|
135
|
+
if (!index) findings.push({ id: id(direction, modelKey, field, 'declared_index_missing'), code: 'declared_index_missing', category: 'physical_contract', severity: 'blocker', direction, phase: 'expand', owner: physicalOwner, model: modelKey, field, column, message: `Field "${modelKey}.${field}" is declared indexed, but "${tableName}.${column}" has no ready, valid index.`, action: 'Create the index concurrently, wait until it is ready and valid, then re-run the plan.' });
|
|
136
|
+
}
|
|
137
|
+
for (const relation of Object.values(model.relations)) if (relation.type === 'belongsTo') {
|
|
138
|
+
const target = schema.models[relation.target];
|
|
139
|
+
const expectedTable = target?.tableName ?? relation.target;
|
|
140
|
+
const constraint = table.foreignKeys?.find((foreignKey) => foreignKey.columns.length === 1 && foreignKey.columns[0] === relation.foreignKeyColumn && foreignKey.referencedTable === expectedTable);
|
|
141
|
+
if (!constraint) findings.push({ id: id(direction, modelKey, relation.foreignKey, 'foreign_key_unverified'), code: 'foreign_key_unverified', category: 'advisory', severity: 'warning', direction, phase: 'verify', owner: physicalOwner, model: modelKey, field: relation.foreignKey, column: relation.foreignKeyColumn, message: `Relation "${modelKey}.${relation.foreignKey}" has no observed PostgreSQL foreign key to "${expectedTable}".`, action: 'If the application database owns relational integrity, add the foreign key NOT VALID and validate it after existing rows are clean.' });
|
|
142
|
+
else if (!constraint.validated) findings.push({ id: id(direction, modelKey, relation.foreignKey, 'foreign_key_not_validated'), code: 'foreign_key_not_validated', category: 'physical_contract', severity: 'warning', direction, phase: 'verify', owner: physicalOwner, model: modelKey, field: relation.foreignKey, column: relation.foreignKeyColumn, message: `Foreign key "${constraint.name}" exists but has not validated existing rows.`, action: 'Run VALIDATE CONSTRAINT under the verification gate before relying on it.' });
|
|
143
|
+
}
|
|
144
|
+
if (database.ownership === 'application' && table.publicationMember === false) findings.push({ id: id(direction, modelKey, 'publication_missing'), code: 'publication_missing', category: 'physical_contract', severity: 'blocker', direction, phase: 'expand', owner: 'application_migration', model: modelKey, message: `Table "${tableName}" is not a member of the connected logical-replication publication.`, action: 'Add the table to the publication through the database owner, then verify replication before activation.' });
|
|
145
|
+
if (table.rowLevelSecurity === true && table.forceRowLevelSecurity !== true) findings.push({ id: id(direction, modelKey, 'rls_not_forced'), code: 'rls_not_forced', category: 'advisory', severity: 'warning', direction, phase: 'verify', owner: physicalOwner, model: modelKey, message: `Table "${tableName}" enables RLS but does not force it for the table owner.`, action: 'Confirm the runtime role cannot bypass the intended policy, or FORCE ROW LEVEL SECURITY.' });
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function reconcileSchemaToDatabase(schema: SchemaJSON, database: DatabaseSnapshot | null, direction: 'source_to_database' | 'active_to_database'): readonly DeploymentFinding[] {
|
|
151
|
+
if (!database) return [{ id: `${direction}:database_unobserved`, code: 'database_unobserved', category: 'observation', severity: 'blocker', direction, phase: 'verify', owner: 'application_migration', message: 'The connected PostgreSQL shape was not observed.', action: 'Provide the application migration credential and re-run the plan against the confirmed database.' }];
|
|
152
|
+
return physicalFindings(schema, database, direction);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Client-runtime projection of the same compatibility finding contract. */
|
|
156
|
+
export function reconcileClientToActive(
|
|
157
|
+
changed: readonly string[],
|
|
158
|
+
unpushed: readonly string[],
|
|
159
|
+
serverLabel: string,
|
|
160
|
+
fieldDifferences: readonly { model: string; field: string; direction: 'client_only' | 'active_only' | 'changed'; detail: string }[] = [],
|
|
161
|
+
): readonly DeploymentFinding[] {
|
|
162
|
+
return [
|
|
163
|
+
...fieldDifferences.map((difference): DeploymentFinding => ({
|
|
164
|
+
id: id('client_to_active', `field_${difference.direction}`, difference.model, difference.field), code: `field_${difference.direction}`,
|
|
165
|
+
category: 'compatibility', severity: difference.direction === 'active_only' ? 'warning' : 'error', direction: 'client_to_active',
|
|
166
|
+
phase: difference.direction === 'active_only' ? 'verify' : 'dual_write', owner: 'application', model: difference.model, field: difference.field,
|
|
167
|
+
message: `Field "${difference.model}.${difference.field}" is ${difference.detail} at ${serverLabel}.`,
|
|
168
|
+
action: 'Run `ablo plan` for the ordered compatibility action before deploying this build.',
|
|
169
|
+
})),
|
|
170
|
+
...changed.map((model): DeploymentFinding => ({
|
|
171
|
+
id: id('client_to_active', 'model_changed', model), code: 'model_changed', category: 'compatibility', severity: 'warning',
|
|
172
|
+
direction: 'client_to_active', phase: 'verify', owner: 'application', model,
|
|
173
|
+
message: `Model "${model}" differs between this build and the active schema at ${serverLabel}.`,
|
|
174
|
+
action: 'Run `ablo plan` to see field direction; use `ablo status` to confirm the deployed target.',
|
|
175
|
+
})),
|
|
176
|
+
...unpushed.map((model): DeploymentFinding => ({
|
|
177
|
+
id: id('client_to_active', 'model_unpushed', model), code: 'model_unpushed', category: 'compatibility', severity: 'error',
|
|
178
|
+
direction: 'client_to_active', phase: 'dual_write', owner: 'ablo', model,
|
|
179
|
+
message: `Model "${model}" is declared by this build but is not active at ${serverLabel}.`,
|
|
180
|
+
action: 'Run `ablo plan`, resolve its gates, then run `ablo push` with the reviewed schema.',
|
|
181
|
+
})),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Turns an explicit lifecycle manifest into the same findings consumed by every surface. */
|
|
186
|
+
export function reconcileDeploymentManifest(manifest: DeploymentManifest | undefined): readonly DeploymentFinding[] {
|
|
187
|
+
if (!manifest) return [];
|
|
188
|
+
const findings: DeploymentFinding[] = [];
|
|
189
|
+
const gates = new Map(manifest.gates.map((gate) => [gate.id, gate]));
|
|
190
|
+
const phases = ['expand', 'dual_write', 'backfill', 'verify', 'switch', 'contract'] as const;
|
|
191
|
+
const targetIndex = phases.indexOf(manifest.targetPhase);
|
|
192
|
+
for (const gate of manifest.gates) {
|
|
193
|
+
const unmet = gate.dependsOn.filter((dependency) => gates.get(dependency)?.status !== 'satisfied');
|
|
194
|
+
const contractWithoutApproval = gate.phase === 'contract' && !gate.approval;
|
|
195
|
+
const future = phases.indexOf(gate.phase) > targetIndex;
|
|
196
|
+
const blocked = !future && (gate.status === 'pending' || unmet.length > 0 || contractWithoutApproval);
|
|
197
|
+
findings.push({
|
|
198
|
+
id: id('manifest', manifest.id, gate.id), code: future ? 'lifecycle_future' : contractWithoutApproval ? 'contract_approval_required' : unmet.length > 0 ? 'lifecycle_dependency_unsatisfied' : `lifecycle_${gate.status}`,
|
|
199
|
+
category: gate.phase === 'contract' ? 'destructive_contract' : gate.phase === 'backfill' ? 'data_movement' : 'compatibility',
|
|
200
|
+
severity: blocked ? 'blocker' : future || gate.status === 'satisfied' ? 'info' : 'warning', direction: 'source_to_active', phase: gate.phase,
|
|
201
|
+
owner: gate.owner, model: gate.resource.split('.')[0], ...(gate.resource.includes('.') ? { field: gate.resource.split('.').slice(1).join('.') } : {}),
|
|
202
|
+
message: future ? `Lifecycle gate "${gate.title}" is visible but belongs after this ${manifest.targetPhase} deployment.` : blocked ? `Lifecycle gate "${gate.title}" is not satisfied${unmet.length ? `; waiting for ${unmet.join(', ')}` : ''}.` : `Lifecycle gate "${gate.title}" is ${gate.status}.`,
|
|
203
|
+
action: contractWithoutApproval ? 'Approve contract as a separate reviewed deployment and record the approval identifier.' : gate.action,
|
|
204
|
+
dependsOn: gate.dependsOn.map((dependency) => id('manifest', manifest.id, dependency)),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (manifest.live) {
|
|
208
|
+
const phasesByResource = new Map<string, Set<string>>();
|
|
209
|
+
for (const gate of manifest.gates) {
|
|
210
|
+
const phases = phasesByResource.get(gate.resource) ?? new Set<string>();
|
|
211
|
+
phases.add(gate.phase);
|
|
212
|
+
phasesByResource.set(gate.resource, phases);
|
|
213
|
+
}
|
|
214
|
+
for (const [resource, phases] of phasesByResource) if (phases.has('expand') && phases.has('contract')) {
|
|
215
|
+
findings.push({ id: id('manifest', manifest.id, resource, 'mixed_expand_contract'), code: 'mixed_expand_contract', category: 'destructive_contract', severity: 'blocker', direction: 'source_to_active', phase: 'contract', owner: 'application', model: resource.split('.')[0], ...(resource.includes('.') ? { field: resource.split('.').slice(1).join('.') } : {}), message: `Live resource "${resource}" combines expand and contract in one deployment manifest.`, action: 'Remove contract from this manifest; deploy and verify expand first, then submit a separately approved contract manifest.' });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return findings;
|
|
219
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { DeploymentFinding, DeploymentPhase, DeploymentStep } from './contracts.js';
|
|
2
|
+
|
|
3
|
+
const PHASES: readonly DeploymentPhase[] = [
|
|
4
|
+
'intent',
|
|
5
|
+
'expand',
|
|
6
|
+
'dual_write',
|
|
7
|
+
'backfill',
|
|
8
|
+
'verify',
|
|
9
|
+
'switch',
|
|
10
|
+
'contract',
|
|
11
|
+
'recover',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function statusOf(findings: readonly DeploymentFinding[]): DeploymentStep['status'] {
|
|
15
|
+
if (findings.some(({ severity }) => severity === 'blocker' || severity === 'error')) {
|
|
16
|
+
return 'blocked';
|
|
17
|
+
}
|
|
18
|
+
if (findings.some(({ code }) => code === 'lifecycle_ready')) return 'ready';
|
|
19
|
+
return findings.every(({ severity }) => severity === 'warning' || severity === 'info')
|
|
20
|
+
? 'advisory'
|
|
21
|
+
: 'ready';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function manifestStep(finding: DeploymentFinding): DeploymentStep {
|
|
25
|
+
const status = statusOf([finding]);
|
|
26
|
+
return {
|
|
27
|
+
id: finding.id,
|
|
28
|
+
phase: finding.phase,
|
|
29
|
+
owner: finding.owner,
|
|
30
|
+
title: finding.message,
|
|
31
|
+
action: finding.action,
|
|
32
|
+
dependsOn: finding.dependsOn ?? [],
|
|
33
|
+
findingIds: [finding.id],
|
|
34
|
+
status,
|
|
35
|
+
executableByAblo: finding.owner === 'ablo' && status === 'ready',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function groupedStep(
|
|
40
|
+
phase: DeploymentPhase,
|
|
41
|
+
owner: DeploymentFinding['owner'],
|
|
42
|
+
findings: readonly DeploymentFinding[],
|
|
43
|
+
): DeploymentStep {
|
|
44
|
+
const status = statusOf(findings);
|
|
45
|
+
return {
|
|
46
|
+
id: `${phase}:${owner}`,
|
|
47
|
+
phase,
|
|
48
|
+
owner,
|
|
49
|
+
title: `${phase} — ${owner.replaceAll('_', ' ')}`,
|
|
50
|
+
action: [...new Set(findings.map(({ action }) => action))].join(' '),
|
|
51
|
+
dependsOn: [],
|
|
52
|
+
findingIds: findings.map(({ id }) => id),
|
|
53
|
+
status,
|
|
54
|
+
executableByAblo: owner === 'ablo' && status === 'ready',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Preserve manifest gates while grouping ordinary diagnostics by phase/owner. */
|
|
59
|
+
export function sequenceDeployment(findings: readonly DeploymentFinding[]): readonly DeploymentStep[] {
|
|
60
|
+
const steps: DeploymentStep[] = [];
|
|
61
|
+
let previousPhase: readonly string[] = [];
|
|
62
|
+
|
|
63
|
+
for (const phase of PHASES) {
|
|
64
|
+
const phaseFindings = findings.filter((finding) => finding.phase === phase);
|
|
65
|
+
const manifest = phaseFindings.filter(({ id }) => id.startsWith('manifest:'));
|
|
66
|
+
const ordinary = phaseFindings.filter(({ id }) => !id.startsWith('manifest:'));
|
|
67
|
+
const phaseSteps = manifest.map(manifestStep);
|
|
68
|
+
|
|
69
|
+
for (const owner of [...new Set(ordinary.map(({ owner }) => owner))]) {
|
|
70
|
+
phaseSteps.push(groupedStep(
|
|
71
|
+
phase,
|
|
72
|
+
owner,
|
|
73
|
+
ordinary.filter((finding) => finding.owner === owner),
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const step of phaseSteps) {
|
|
78
|
+
const explicit = step.dependsOn;
|
|
79
|
+
const barrier = explicit.length === 0 ? previousPhase : [];
|
|
80
|
+
steps.push({ ...step, dependsOn: [...new Set([...explicit, ...barrier])] });
|
|
81
|
+
}
|
|
82
|
+
if (phaseSteps.length > 0) previousPhase = phaseSteps.map(({ id }) => id);
|
|
83
|
+
}
|
|
84
|
+
return steps;
|
|
85
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -262,6 +262,11 @@ export {
|
|
|
262
262
|
type BlockerCode,
|
|
263
263
|
} from './diff.js';
|
|
264
264
|
|
|
265
|
+
// One source/active/database deployment skeleton. CLI check/plan/push/migrate,
|
|
266
|
+
// server activation, and runtime drift project this contract instead of
|
|
267
|
+
// maintaining lateral planners.
|
|
268
|
+
export * from './deployment/index.js';
|
|
269
|
+
|
|
265
270
|
// Schema → TypeScript type emission.
|
|
266
271
|
export { generateTypes } from './generate.js';
|
|
267
272
|
|