@abloatai/transaction 0.58.0 → 0.59.1
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 +120 -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 +318 -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 +132 -0
- package/src/schema/deployment/postgresCatalog.ts +131 -0
- package/src/schema/deployment/reconcile.ts +335 -0
- package/src/schema/deployment/sequence.ts +85 -0
- package/src/schema/index.ts +5 -0
|
@@ -0,0 +1,335 @@
|
|
|
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
|
+
function databaseSatisfiesRequiredField(
|
|
53
|
+
signal: MigrationSignal,
|
|
54
|
+
source: SchemaJSON,
|
|
55
|
+
database: DatabaseSnapshot | null,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!database || (signal.code !== 'required_field_added' && signal.code !== 'made_required') || !signal.field) return false;
|
|
58
|
+
const model = source.models[signal.model];
|
|
59
|
+
const field = model?.fields[signal.field];
|
|
60
|
+
if (!model || !field || field.isOptional) return false;
|
|
61
|
+
const table = database.tables[model.tableName ?? signal.model];
|
|
62
|
+
const column = table?.columns[field.column ?? camelToSnake(signal.field)];
|
|
63
|
+
return column !== undefined &&
|
|
64
|
+
!column.nullable &&
|
|
65
|
+
(column.nullCount === undefined || column.nullCount === null || column.nullCount === 0) &&
|
|
66
|
+
normalizePgType(column.dataType) === sqlType(field.type);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function databaseSatisfiesRiskyCast(
|
|
70
|
+
signal: MigrationSignal,
|
|
71
|
+
source: SchemaJSON,
|
|
72
|
+
database: DatabaseSnapshot | null,
|
|
73
|
+
): boolean {
|
|
74
|
+
if (!database || signal.code !== 'risky_cast' || !signal.field) return false;
|
|
75
|
+
const model = source.models[signal.model];
|
|
76
|
+
const field = model?.fields[signal.field];
|
|
77
|
+
// A TEXT column does not prove that existing rows satisfy a newly narrowed
|
|
78
|
+
// enum CHECK constraint; the catalog snapshot currently observes type only.
|
|
79
|
+
if (!model || !field || field.type === 'enum') return false;
|
|
80
|
+
const table = database.tables[model.tableName ?? signal.model];
|
|
81
|
+
const column = table?.columns[field.column ?? camelToSnake(signal.field)];
|
|
82
|
+
return column !== undefined && normalizePgType(column.dataType) === sqlType(field.type);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function databasePreservesRemovedApplicationModel(
|
|
86
|
+
signal: MigrationSignal,
|
|
87
|
+
active: SchemaJSON | null,
|
|
88
|
+
source: SchemaJSON,
|
|
89
|
+
database: DatabaseSnapshot | null,
|
|
90
|
+
): boolean {
|
|
91
|
+
if (!active || !database || database.ownership !== 'application' || signal.code !== 'drop_model') return false;
|
|
92
|
+
if (source.models[signal.model]) return false;
|
|
93
|
+
const activeModel = active.models[signal.model];
|
|
94
|
+
return activeModel !== undefined && database.tables[activeModel.tableName ?? signal.model] !== undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function reconcileSourceToActive(
|
|
98
|
+
active: SchemaJSON | null,
|
|
99
|
+
source: SchemaJSON,
|
|
100
|
+
hints: RenameHints = {},
|
|
101
|
+
backfills: readonly BackfillValue[] = [],
|
|
102
|
+
database: DatabaseSnapshot | null = null,
|
|
103
|
+
): readonly DeploymentFinding[] {
|
|
104
|
+
return reconcileSourceToActiveResult(active, source, hints, backfills, database).findings;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function reconcileSourceToActiveResult(
|
|
108
|
+
active: SchemaJSON | null,
|
|
109
|
+
source: SchemaJSON,
|
|
110
|
+
hints: RenameHints = {},
|
|
111
|
+
backfills: readonly BackfillValue[] = [],
|
|
112
|
+
database: DatabaseSnapshot | null = null,
|
|
113
|
+
): { operations: readonly MigrationStep[]; findings: readonly DeploymentFinding[] } {
|
|
114
|
+
const steps = diffSchema(active, source, hints);
|
|
115
|
+
const classification = classifyMigration(steps);
|
|
116
|
+
const blockers = unresolvedBlockers(classification, backfills);
|
|
117
|
+
const requiredFieldsVerified = blockers.filter((signal) => databaseSatisfiesRequiredField(signal, source, database));
|
|
118
|
+
const typeCorrectionsVerified = classification.warnings.filter((signal) => databaseSatisfiesRiskyCast(signal, source, database));
|
|
119
|
+
const removedApplicationModelsPreserved = classification.warnings.filter((signal) =>
|
|
120
|
+
databasePreservesRemovedApplicationModel(signal, active, source, database)
|
|
121
|
+
);
|
|
122
|
+
const databaseVerified = new Set<MigrationSignal>([
|
|
123
|
+
...requiredFieldsVerified,
|
|
124
|
+
...typeCorrectionsVerified,
|
|
125
|
+
...removedApplicationModelsPreserved,
|
|
126
|
+
]);
|
|
127
|
+
const signals = [
|
|
128
|
+
...blockers.filter((signal) => !databaseVerified.has(signal)),
|
|
129
|
+
...classification.warnings.filter((signal) => !databaseVerified.has(signal)),
|
|
130
|
+
].map((signal) => signalFinding(signal, 'source_to_active'));
|
|
131
|
+
// Keep every classified location out of the ordinary step findings, including
|
|
132
|
+
// blockers discharged by observed PostgreSQL evidence. Otherwise a verified
|
|
133
|
+
// required field falls through and is emitted again as an add_field blocker.
|
|
134
|
+
const signalLocations = new Set([...blockers, ...classification.warnings].map(({ model, field }) => `${model}:${field ?? ''}`));
|
|
135
|
+
const ordinary = steps
|
|
136
|
+
.map(stepFinding)
|
|
137
|
+
.filter((finding): finding is DeploymentFinding => finding !== null)
|
|
138
|
+
.filter((finding) => !signalLocations.has(`${finding.model}:${finding.field ?? ''}`))
|
|
139
|
+
.map((finding) => {
|
|
140
|
+
const hasBackfill = finding.model !== undefined && finding.field !== undefined &&
|
|
141
|
+
backfills.some((backfill) => backfill.model === finding.model && backfill.field === finding.field);
|
|
142
|
+
return hasBackfill && finding.code === 'add_field' && finding.severity === 'blocker'
|
|
143
|
+
? {
|
|
144
|
+
...finding,
|
|
145
|
+
severity: 'info' as const,
|
|
146
|
+
phase: 'backfill' as const,
|
|
147
|
+
action: 'Run the declared backfill, verify it completed, then enforce the required contract.',
|
|
148
|
+
}
|
|
149
|
+
: finding;
|
|
150
|
+
});
|
|
151
|
+
const requiredFieldEvidence: DeploymentFinding[] = requiredFieldsVerified.length === 0 ? [] : [{
|
|
152
|
+
id: 'source_to_active:database_migration_verified',
|
|
153
|
+
code: 'database_migration_verified',
|
|
154
|
+
category: 'data_movement',
|
|
155
|
+
severity: 'info',
|
|
156
|
+
direction: 'source_to_active',
|
|
157
|
+
phase: 'verify',
|
|
158
|
+
owner: database?.ownership === 'ablo' ? 'ablo' : 'application_migration',
|
|
159
|
+
from: { requiredChanges: requiredFieldsVerified.length },
|
|
160
|
+
to: 'satisfied',
|
|
161
|
+
message: `PostgreSQL already enforces ${requiredFieldsVerified.length} required field migration(s) absent from the active artifact.`,
|
|
162
|
+
action: 'No synthetic backfill declaration is needed; preserve the matching non-null columns and activate the reviewed schema.',
|
|
163
|
+
}];
|
|
164
|
+
const typeCorrectionEvidence: DeploymentFinding[] = typeCorrectionsVerified.length === 0 ? [] : [{
|
|
165
|
+
id: 'source_to_active:database_type_correction_verified',
|
|
166
|
+
code: 'database_type_correction_verified',
|
|
167
|
+
category: 'destructive_contract',
|
|
168
|
+
severity: 'info',
|
|
169
|
+
direction: 'source_to_active',
|
|
170
|
+
phase: 'verify',
|
|
171
|
+
owner: database?.ownership === 'ablo' ? 'ablo' : 'application_migration',
|
|
172
|
+
from: { typeCorrections: typeCorrectionsVerified.length },
|
|
173
|
+
to: 'source_database_alignment',
|
|
174
|
+
message: `PostgreSQL already matches ${typeCorrectionsVerified.length} candidate field type correction(s) that differ from the active artifact.`,
|
|
175
|
+
action: 'No physical cast is needed; activate the reviewed schema and use forward recovery because the prior artifact no longer matches PostgreSQL.',
|
|
176
|
+
}];
|
|
177
|
+
const removedModelEvidence: DeploymentFinding[] = removedApplicationModelsPreserved.length === 0 ? [] : [{
|
|
178
|
+
id: 'source_to_active:application_model_removal_verified',
|
|
179
|
+
code: 'application_model_removal_verified',
|
|
180
|
+
category: 'compatibility',
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
direction: 'source_to_active',
|
|
183
|
+
phase: 'verify',
|
|
184
|
+
owner: 'application',
|
|
185
|
+
from: { removedModels: removedApplicationModelsPreserved.length },
|
|
186
|
+
to: 'physical_tables_preserved',
|
|
187
|
+
message: `${removedApplicationModelsPreserved.length} model removal(s) only change the served schema; their application-owned PostgreSQL tables remain present.`,
|
|
188
|
+
action: 'Verify no supported client still reads these models, then activate; Ablo will not drop the preserved application tables.',
|
|
189
|
+
}];
|
|
190
|
+
return {
|
|
191
|
+
operations: steps,
|
|
192
|
+
findings: [...signals, ...requiredFieldEvidence, ...typeCorrectionEvidence, ...removedModelEvidence, ...ordinary],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function reconcilePolicyIntent(source: SchemaJSON): readonly DeploymentFinding[] {
|
|
197
|
+
return auditSchemaAccessPolicies(source).map((finding) => ({
|
|
198
|
+
id: id('policy', finding.code, finding.model), code: finding.code, category: 'policy_intent', severity: 'blocker', direction: 'source_to_active',
|
|
199
|
+
phase: 'intent', owner: 'application', model: finding.model, message: finding.message, action: finding.fix,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizePgType(value: string): string {
|
|
204
|
+
const type = value.toLowerCase();
|
|
205
|
+
if (type.includes('timestamp')) return 'TIMESTAMPTZ';
|
|
206
|
+
if (type === 'double precision' || type === 'numeric' || type === 'real' || type.includes('int')) return 'DOUBLE PRECISION';
|
|
207
|
+
if (type === 'boolean') return 'BOOLEAN';
|
|
208
|
+
if (type === 'json' || type === 'jsonb') return 'JSONB';
|
|
209
|
+
return 'TEXT';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function requiredColumns(model: ModelJSON): readonly { field?: string; column: string; type: string; nullable: boolean; primary: boolean }[] {
|
|
213
|
+
const orgColumn = tenancyColumn(resolveTenancy(model));
|
|
214
|
+
return [
|
|
215
|
+
{ column: 'id', type: 'TEXT', nullable: false, primary: true },
|
|
216
|
+
...(orgColumn ? [{ column: orgColumn, type: 'TEXT', nullable: false, primary: false }] : []),
|
|
217
|
+
...Object.entries(model.fields).map(([field, meta]) => ({ field, column: meta.column ?? camelToSnake(field), type: sqlType(meta.type), nullable: meta.isOptional, primary: false })),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function physicalFindings(schema: SchemaJSON, database: DatabaseSnapshot, direction: 'source_to_database' | 'active_to_database'): DeploymentFinding[] {
|
|
222
|
+
const findings: DeploymentFinding[] = [];
|
|
223
|
+
const physicalOwner = database.ownership === 'ablo' ? 'ablo' : 'application_migration';
|
|
224
|
+
for (const [modelKey, model] of Object.entries(schema.models)) {
|
|
225
|
+
if ((model.plane ?? 'tenant') === 'control') continue;
|
|
226
|
+
const tableName = model.tableName ?? modelKey;
|
|
227
|
+
const table = database.tables[tableName];
|
|
228
|
+
if (!table) {
|
|
229
|
+
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.' });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const expected of requiredColumns(model)) {
|
|
233
|
+
const actual = table.columns[expected.column];
|
|
234
|
+
const base = [direction, modelKey, expected.field, expected.column];
|
|
235
|
+
if (!actual) {
|
|
236
|
+
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.' });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
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.' });
|
|
240
|
+
const actualType = normalizePgType(actual.dataType);
|
|
241
|
+
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.' });
|
|
242
|
+
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.' });
|
|
243
|
+
if (expected.column === tenancyColumn(resolveTenancy(model)) && actual.nullCount !== undefined && actual.nullCount !== null && actual.nullCount > 0) {
|
|
244
|
+
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`.' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
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.' });
|
|
248
|
+
for (const [field, meta] of Object.entries(model.fields)) if (meta.isIndexed) {
|
|
249
|
+
const column = meta.column ?? camelToSnake(field);
|
|
250
|
+
const index = table.indexes?.find((candidate) => candidate.columns.length === 1 && candidate.columns[0] === column && candidate.valid && candidate.ready);
|
|
251
|
+
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.' });
|
|
252
|
+
}
|
|
253
|
+
for (const relation of Object.values(model.relations)) if (relation.type === 'belongsTo') {
|
|
254
|
+
const target = schema.models[relation.target];
|
|
255
|
+
const expectedTable = target?.tableName ?? relation.target;
|
|
256
|
+
const constraint = table.foreignKeys?.find((foreignKey) => foreignKey.columns.length === 1 && foreignKey.columns[0] === relation.foreignKeyColumn && foreignKey.referencedTable === expectedTable);
|
|
257
|
+
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.' });
|
|
258
|
+
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.' });
|
|
259
|
+
}
|
|
260
|
+
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.' });
|
|
261
|
+
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.' });
|
|
262
|
+
}
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function reconcileSchemaToDatabase(schema: SchemaJSON, database: DatabaseSnapshot | null, direction: 'source_to_database' | 'active_to_database'): readonly DeploymentFinding[] {
|
|
267
|
+
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.' }];
|
|
268
|
+
return physicalFindings(schema, database, direction);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Client-runtime projection of the same compatibility finding contract. */
|
|
272
|
+
export function reconcileClientToActive(
|
|
273
|
+
changed: readonly string[],
|
|
274
|
+
unpushed: readonly string[],
|
|
275
|
+
serverLabel: string,
|
|
276
|
+
fieldDifferences: readonly { model: string; field: string; direction: 'client_only' | 'active_only' | 'changed'; detail: string }[] = [],
|
|
277
|
+
): readonly DeploymentFinding[] {
|
|
278
|
+
return [
|
|
279
|
+
...fieldDifferences.map((difference): DeploymentFinding => ({
|
|
280
|
+
id: id('client_to_active', `field_${difference.direction}`, difference.model, difference.field), code: `field_${difference.direction}`,
|
|
281
|
+
category: 'compatibility', severity: difference.direction === 'active_only' ? 'warning' : 'error', direction: 'client_to_active',
|
|
282
|
+
phase: difference.direction === 'active_only' ? 'verify' : 'dual_write', owner: 'application', model: difference.model, field: difference.field,
|
|
283
|
+
message: `Field "${difference.model}.${difference.field}" is ${difference.detail} at ${serverLabel}.`,
|
|
284
|
+
action: 'Run `ablo plan` for the ordered compatibility action before deploying this build.',
|
|
285
|
+
})),
|
|
286
|
+
...changed.map((model): DeploymentFinding => ({
|
|
287
|
+
id: id('client_to_active', 'model_changed', model), code: 'model_changed', category: 'compatibility', severity: 'warning',
|
|
288
|
+
direction: 'client_to_active', phase: 'verify', owner: 'application', model,
|
|
289
|
+
message: `Model "${model}" differs between this build and the active schema at ${serverLabel}.`,
|
|
290
|
+
action: 'Run `ablo plan` to see field direction; use `ablo status` to confirm the deployed target.',
|
|
291
|
+
})),
|
|
292
|
+
...unpushed.map((model): DeploymentFinding => ({
|
|
293
|
+
id: id('client_to_active', 'model_unpushed', model), code: 'model_unpushed', category: 'compatibility', severity: 'error',
|
|
294
|
+
direction: 'client_to_active', phase: 'dual_write', owner: 'ablo', model,
|
|
295
|
+
message: `Model "${model}" is declared by this build but is not active at ${serverLabel}.`,
|
|
296
|
+
action: 'Run `ablo plan`, resolve its gates, then run `ablo push` with the reviewed schema.',
|
|
297
|
+
})),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Turns an explicit lifecycle manifest into the same findings consumed by every surface. */
|
|
302
|
+
export function reconcileDeploymentManifest(manifest: DeploymentManifest | undefined): readonly DeploymentFinding[] {
|
|
303
|
+
if (!manifest) return [];
|
|
304
|
+
const findings: DeploymentFinding[] = [];
|
|
305
|
+
const gates = new Map(manifest.gates.map((gate) => [gate.id, gate]));
|
|
306
|
+
const phases = ['expand', 'dual_write', 'backfill', 'verify', 'switch', 'contract'] as const;
|
|
307
|
+
const targetIndex = phases.indexOf(manifest.targetPhase);
|
|
308
|
+
for (const gate of manifest.gates) {
|
|
309
|
+
const unmet = gate.dependsOn.filter((dependency) => gates.get(dependency)?.status !== 'satisfied');
|
|
310
|
+
const contractWithoutApproval = gate.phase === 'contract' && !gate.approval;
|
|
311
|
+
const future = phases.indexOf(gate.phase) > targetIndex;
|
|
312
|
+
const blocked = !future && (gate.status === 'pending' || unmet.length > 0 || contractWithoutApproval);
|
|
313
|
+
findings.push({
|
|
314
|
+
id: id('manifest', manifest.id, gate.id), code: future ? 'lifecycle_future' : contractWithoutApproval ? 'contract_approval_required' : unmet.length > 0 ? 'lifecycle_dependency_unsatisfied' : `lifecycle_${gate.status}`,
|
|
315
|
+
category: gate.phase === 'contract' ? 'destructive_contract' : gate.phase === 'backfill' ? 'data_movement' : 'compatibility',
|
|
316
|
+
severity: blocked ? 'blocker' : future || gate.status === 'satisfied' ? 'info' : 'warning', direction: 'source_to_active', phase: gate.phase,
|
|
317
|
+
owner: gate.owner, model: gate.resource.split('.')[0], ...(gate.resource.includes('.') ? { field: gate.resource.split('.').slice(1).join('.') } : {}),
|
|
318
|
+
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}.`,
|
|
319
|
+
action: contractWithoutApproval ? 'Approve contract as a separate reviewed deployment and record the approval identifier.' : gate.action,
|
|
320
|
+
dependsOn: gate.dependsOn.map((dependency) => id('manifest', manifest.id, dependency)),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (manifest.live) {
|
|
324
|
+
const phasesByResource = new Map<string, Set<string>>();
|
|
325
|
+
for (const gate of manifest.gates) {
|
|
326
|
+
const phases = phasesByResource.get(gate.resource) ?? new Set<string>();
|
|
327
|
+
phases.add(gate.phase);
|
|
328
|
+
phasesByResource.set(gate.resource, phases);
|
|
329
|
+
}
|
|
330
|
+
for (const [resource, phases] of phasesByResource) if (phases.has('expand') && phases.has('contract')) {
|
|
331
|
+
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.' });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return findings;
|
|
335
|
+
}
|
|
@@ -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
|
|