@happyvertical/smrt-cli 0.51.38 → 0.52.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/AGENTS.md +6 -2
- package/README.md +21 -0
- package/agents/type-drift.md +16 -0
- package/dist/{ApiKey-BZmbOTnL-ByurmqBP.js → ApiKey-BZmbOTnL-CMUo3WLf.js} +2 -2
- package/dist/ApiKeyCollection-Bfb_nw81-C3Xz7MUq.js +2 -0
- package/dist/AuditLogCollection-B1YonCAP-DmmboC_X.js +2 -0
- package/dist/{NostrIdentity-n70E0vck-77xzc_PL.js → NostrIdentity-n70E0vck-BD7uutxz.js} +3 -3
- package/dist/NostrIdentityCollection-Ca74dkaL--KYlM_ou.js +2 -0
- package/dist/{OidcIdentity-K8V4u0CR-DGH7BcbK.js → OidcIdentity-Cb1bxreM-DY0aRLu-.js} +3 -3
- package/dist/OidcIdentityCollection-TV8VdK69-DPeFJt3R.js +2 -0
- package/dist/ProfileAssetCollection-B9hacUJa-6R8lwjti.js +2 -0
- package/dist/{ProfileCollection-DTT4KNpQ-EOwNsjMO.js → ProfileCollection-DuJT7Mum-D_ScbSMu.js} +69 -31
- package/dist/ProfileLinkCollection-4uNURz32-lMKBjaik.js +2 -0
- package/dist/ProfileMetadataCollection-DQhbfEfa-B1QlmBkX.js +2 -0
- package/dist/{ProfileMetafieldCollection-CMKhVi2I-DV929zCg.js → ProfileMetafieldCollection-CoBuM_5G-BDwo4x3z.js} +1 -1
- package/dist/{ProfileRelationshipCollection-DUVgaIqf-CcshDm6V.js → ProfileRelationshipCollection-DUVgaIqf-BRrBeg1Q.js} +1 -1
- package/dist/{ProfileRelationshipTermCollection-DtA9OM23-Bv387Zjn.js → ProfileRelationshipTermCollection-DtA9OM23-D4WMfN9a.js} +1 -1
- package/dist/{ProfileRelationshipType-BRiRka6t-D_AAdU8t.js → ProfileRelationshipType-BRiRka6t-B32A4UFT.js} +1 -1
- package/dist/{ProfileRelationshipTypeCollection-BdXwd7Kf-G65p6TO9.js → ProfileRelationshipTypeCollection-BdXwd7Kf-BqQa8Awc.js} +1 -1
- package/dist/commands/db-diff.d.ts.map +1 -1
- package/dist/commands/db-history.d.ts.map +1 -1
- package/dist/commands/db-migrate-actions.d.ts +7 -0
- package/dist/commands/db-migrate-actions.d.ts.map +1 -1
- package/dist/commands/db-parity.d.ts +7 -0
- package/dist/commands/db-parity.d.ts.map +1 -1
- package/dist/commands/db-status.d.ts.map +1 -1
- package/dist/commands/tenant-natural-keys.d.ts +58 -0
- package/dist/commands/tenant-natural-keys.d.ts.map +1 -0
- package/dist/commands/utilities.d.ts.map +1 -1
- package/dist/{commands-C-5eYXrj.js → commands-BvcMfEoO.js} +165 -18
- package/dist/{dist-BR-p9c3W.js → dist-BTxdRxKP.js} +151 -6
- package/dist/{dist-DM4d1-zt.js → dist-Dm4q4uB4.js} +4 -4
- package/dist/dist-qJw7ALwV.js +1701 -0
- package/dist/dist-vJJL0WMc.js +3937 -0
- package/dist/index.js +9 -9
- package/dist/{json-validator-oEN7eUI3.js → json-validator-BiAdsE1O.js} +4 -4
- package/dist/{oidcProvisioningPrimitives-0-3n3D6f-i-c_7gKx.js → oidcProvisioningPrimitives-D9OYu5-F-D9DQdiwS.js} +7 -7
- package/dist/resolveIdentity-B-PK6RwU-Dvqeq9-I.js +2 -0
- package/package.json +14 -14
- package/dist/ApiKeyCollection-Bfb_nw81-BU5J7ey6.js +0 -2
- package/dist/AuditLogCollection-B1YonCAP-BV-Al2kY.js +0 -2
- package/dist/NostrIdentityCollection-Ca74dkaL-BlakR6xt.js +0 -2
- package/dist/OidcIdentityCollection-gnrLoF08-DKBQMjV4.js +0 -2
- package/dist/ProfileAssetCollection-CmbeSgDi-Boorc8a7.js +0 -2
- package/dist/ProfileMetadataCollection-DxX_BJKV-D0bkd5ul.js +0 -2
- package/dist/dist-BvocmGd3.js +0 -3712
- package/dist/dist-P3J4KmtN.js +0 -1487
- package/dist/resolveIdentity-BrJDCjYi-jUicYehv.js +0 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-natural-keys.d.ts","sourceRoot":"","sources":["../../src/commands/tenant-natural-keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,yEAAyE;AACzE,KAAK,aAAa,GAAG;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,aAAa,GAAG;IAAE,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC;AAE7E;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE;QACP,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,EAAE,CAAC;QAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,eAAe,GAAG,sBAAsB,GAAG,uBAAuB,CAAC;KAC1E,CAAC;CACH;AAMD;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,GAC9C,uBAAuB,EAAE,CAwG3B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../../src/commands/utilities.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAwBH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAwItD,wBAAgB,uBAAuB,CAAC,OAAO,SAAgB,GAAG,MAAM,CAIvE;
|
|
1
|
+
{"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../../src/commands/utilities.ts"],"names":[],"mappings":"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"}
|
|
@@ -13,7 +13,7 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
13
13
|
import glob from "fast-glob";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { getPackageConfig } from "@happyvertical/smrt-config";
|
|
16
|
-
import { toSnakeCase } from "@happyvertical/smrt-core/utils";
|
|
16
|
+
import { toSnakeCase as toSnakeCase$1 } from "@happyvertical/smrt-core/utils";
|
|
17
17
|
import { checkKnowledgeGraphFreshness, discoverScopedPackageDirectories, readAgentModuleDocs, readPackageAgentDoc, resolveAgentModuleDocPaths } from "@happyvertical/smrt-core/knowledge";
|
|
18
18
|
import { MCPGenerator } from "@happyvertical/smrt-core/generators";
|
|
19
19
|
import { generateDeclarationsFromCLI } from "@happyvertical/smrt-core/prebuild";
|
|
@@ -767,7 +767,8 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
767
767
|
...change.foreignKey ? { foreignKey: change.foreignKey } : {},
|
|
768
768
|
...change.orphanBlocked !== void 0 ? { orphanBlocked: change.orphanBlocked } : {},
|
|
769
769
|
...change.orphanNullable !== void 0 ? { orphanNullable: change.orphanNullable } : {},
|
|
770
|
-
...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {}
|
|
770
|
+
...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {},
|
|
771
|
+
...change.note ? { note: change.note } : {}
|
|
771
772
|
};
|
|
772
773
|
if (isAdvisoryOnlyChangeLike(change)) manualInterventions.push(action);
|
|
773
774
|
else migrations.push(action);
|
|
@@ -833,7 +834,8 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
833
834
|
actual: mm.actual
|
|
834
835
|
},
|
|
835
836
|
sql: change.sql,
|
|
836
|
-
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
837
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {},
|
|
838
|
+
...change.note ? { note: change.note } : {}
|
|
837
839
|
};
|
|
838
840
|
const executionKind = classifyTypeUpgradeSql(change.sql);
|
|
839
841
|
if (executionKind === "executable") migrations.push(action);
|
|
@@ -1230,6 +1232,11 @@ var dbDiffCommand = {
|
|
|
1230
1232
|
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
1231
1233
|
default: false
|
|
1232
1234
|
},
|
|
1235
|
+
"drop-legacy-natural-key": {
|
|
1236
|
+
type: "boolean",
|
|
1237
|
+
description: "Include the drop of the legacy global (slug, context) unique index a tenant-led natural key superseded (see db:migrate --drop-legacy-natural-key).",
|
|
1238
|
+
default: false
|
|
1239
|
+
},
|
|
1233
1240
|
"drop-columns": {
|
|
1234
1241
|
type: "boolean",
|
|
1235
1242
|
description: "Include orphan-column drops in the diff (columns in DB but not in the manifest). Off by default; orphans are always reported.",
|
|
@@ -1243,6 +1250,11 @@ var dbDiffCommand = {
|
|
|
1243
1250
|
"postgres-timestamp-legacy-timezone": {
|
|
1244
1251
|
type: "string",
|
|
1245
1252
|
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
1253
|
+
},
|
|
1254
|
+
"empty-text-as-null": {
|
|
1255
|
+
type: "boolean",
|
|
1256
|
+
description: "Preview storing empty or whitespace-only text as NULL when converging a nullable legacy text column to a typed column (timestamp, JSON, integer). Any other value that does not convert still blocks. Off by default; the diff names the empty-text count instead.",
|
|
1257
|
+
default: false
|
|
1246
1258
|
}
|
|
1247
1259
|
},
|
|
1248
1260
|
handler: async (_args, options) => {
|
|
@@ -1298,8 +1310,10 @@ var dbDiffCommand = {
|
|
|
1298
1310
|
includeDroppedTables: false,
|
|
1299
1311
|
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
1300
1312
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
1313
|
+
dropLegacyNaturalKey: Boolean(options["drop-legacy-natural-key"]),
|
|
1301
1314
|
relaxColumns: Boolean(options["relax-columns"]),
|
|
1302
|
-
postgresTimestampMigration
|
|
1315
|
+
postgresTimestampMigration,
|
|
1316
|
+
emptyTextAsNull: Boolean(options["empty-text-as-null"])
|
|
1303
1317
|
}).compare(schemaDefinitions);
|
|
1304
1318
|
if (options.json) {
|
|
1305
1319
|
console.log(JSON.stringify({
|
|
@@ -1394,9 +1408,21 @@ var dbDiffCommand = {
|
|
|
1394
1408
|
}
|
|
1395
1409
|
if (typeUpgrades.length > 0) {
|
|
1396
1410
|
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
1397
|
-
for (const change of typeUpgrades)
|
|
1411
|
+
for (const change of typeUpgrades) {
|
|
1412
|
+
console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
1413
|
+
if (change.note) console.log(` (${change.note})`);
|
|
1414
|
+
}
|
|
1398
1415
|
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
1399
1416
|
}
|
|
1417
|
+
const foreignKeyChanges = diff.changes.filter((c) => c.type === "add_foreign_key" && (c.sql || c.sqlStatements?.length));
|
|
1418
|
+
if (foreignKeyChanges.length > 0) {
|
|
1419
|
+
console.log(` 🔗 Foreign keys (${foreignKeyChanges.length}):`);
|
|
1420
|
+
for (const change of foreignKeyChanges) {
|
|
1421
|
+
console.log(` + ${change.name} on ${change.table}${change.note ? ` (${change.note})` : ""}`);
|
|
1422
|
+
if (change.advisory?.severity === "warning") console.log(` ⚠️ ${change.advisory.message}`);
|
|
1423
|
+
}
|
|
1424
|
+
console.log();
|
|
1425
|
+
}
|
|
1400
1426
|
if (typeMismatches.length > 0) {
|
|
1401
1427
|
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
1402
1428
|
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
@@ -1581,6 +1607,11 @@ var dbHistoryCommand = {
|
|
|
1581
1607
|
description: "Show full details including checksums",
|
|
1582
1608
|
default: false,
|
|
1583
1609
|
short: "v"
|
|
1610
|
+
},
|
|
1611
|
+
"empty-text-as-null": {
|
|
1612
|
+
type: "boolean",
|
|
1613
|
+
description: "Assess the same convergence `db:migrate --empty-text-as-null` would apply: a nullable legacy text column whose only obstacle is empty or whitespace-only text is pending, not blocked. Off by default.",
|
|
1614
|
+
default: false
|
|
1584
1615
|
}
|
|
1585
1616
|
},
|
|
1586
1617
|
handler: async (_args, options) => {
|
|
@@ -1619,7 +1650,7 @@ var dbHistoryCommand = {
|
|
|
1619
1650
|
if (failedHistory.length > 0 && typeof db.getTableSchema === "function") try {
|
|
1620
1651
|
await autoDiscoverAndLoad();
|
|
1621
1652
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
1622
|
-
const diff = await new SchemaComparer(db).compare(manifestSchemas);
|
|
1653
|
+
const diff = await new SchemaComparer(db, { emptyTextAsNull: Boolean(options["empty-text-as-null"]) }).compare(manifestSchemas);
|
|
1623
1654
|
liveSchemaCompared = true;
|
|
1624
1655
|
failedAssessments = assessFailedMigrations(failedHistory, diff);
|
|
1625
1656
|
} catch {
|
|
@@ -2868,7 +2899,7 @@ async function evaluateSchemaContract(options) {
|
|
|
2868
2899
|
present = (await ObjectRegistry.getAllFields(entry.name)).has(fieldName);
|
|
2869
2900
|
if (present) {
|
|
2870
2901
|
table = inferTableName(entry);
|
|
2871
|
-
column = toSnakeCase(fieldName);
|
|
2902
|
+
column = toSnakeCase$1(fieldName);
|
|
2872
2903
|
if (table) {
|
|
2873
2904
|
inferredTables.add(table);
|
|
2874
2905
|
inferredColumns.add(`${table}.${column}`);
|
|
@@ -2928,6 +2959,99 @@ function assertSchemaContract(report) {
|
|
|
2928
2959
|
if (!report.ok) throw new SchemaContractError(report);
|
|
2929
2960
|
}
|
|
2930
2961
|
//#endregion
|
|
2962
|
+
//#region src/commands/tenant-natural-keys.ts
|
|
2963
|
+
/**
|
|
2964
|
+
* Tenant natural-key detector for `db:status`.
|
|
2965
|
+
*
|
|
2966
|
+
* A tenant-owned table (declared tenant scope, or an undeclared `tenantId`
|
|
2967
|
+
* field — `ObjectRegistry.getOwnershipTenantColumn()`) upserts on a
|
|
2968
|
+
* tenant-led key such as `(tenant_id, slug, context)`. A live database created
|
|
2969
|
+
* before that default can still carry the GLOBAL `(slug, context)` unique:
|
|
2970
|
+
* then a second tenant's same-slug save collides with a row it cannot see.
|
|
2971
|
+
* Before the save-time ownership guard, `ON CONFLICT … DO UPDATE` rewrote that
|
|
2972
|
+
* row's `id` and `tenant_id` (the Anytown Ludis takeover); now the save is
|
|
2973
|
+
* refused, and on engines that require an exact ON CONFLICT target the create
|
|
2974
|
+
* fails until `smrt db:migrate` swaps the index.
|
|
2975
|
+
*
|
|
2976
|
+
* Rollout is expand, then contract: `smrt db:migrate` builds the tenant-led
|
|
2977
|
+
* unique beside the global one (an `error` until it exists, so db:status exits
|
|
2978
|
+
* 1), and the global one stays — reported as a `warning` — until
|
|
2979
|
+
* `smrt db:migrate --drop-legacy-natural-key` once no old code is running.
|
|
2980
|
+
* Explicit `@smrt({ conflictColumns })` that omit the tenant column are the
|
|
2981
|
+
* author's declared contract and are not reported here (the tenancy
|
|
2982
|
+
* package's `auditTenantScopedRegistrations()` flags runtime-registered ones).
|
|
2983
|
+
*/
|
|
2984
|
+
function sameColumnSet(a, b) {
|
|
2985
|
+
return a.length === b.length && a.every((column) => b.includes(column));
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Compare every tenant-owned table's upsert target with its live unique
|
|
2989
|
+
* indexes. Tables absent from `liveSchemas` (not created yet, or not
|
|
2990
|
+
* introspected) are skipped — the schema diff reports those.
|
|
2991
|
+
*/
|
|
2992
|
+
function checkTenantNaturalKeyUniques(liveSchemas) {
|
|
2993
|
+
const findings = [];
|
|
2994
|
+
const seenTables = /* @__PURE__ */ new Set();
|
|
2995
|
+
for (const className of ObjectRegistry.getQualifiedClassNames()) {
|
|
2996
|
+
if (isCollectionRegistration(className)) continue;
|
|
2997
|
+
const tableName = ObjectRegistry.getTableName(className);
|
|
2998
|
+
if (!tableName || seenTables.has(tableName)) continue;
|
|
2999
|
+
const tenantColumn = ObjectRegistry.getOwnershipTenantColumn(className);
|
|
3000
|
+
if (!tenantColumn) continue;
|
|
3001
|
+
const conflictColumns = ObjectRegistry.getConflictColumns(className);
|
|
3002
|
+
if (!conflictColumns.includes(tenantColumn)) continue;
|
|
3003
|
+
const live = liveSchemas.get(tableName);
|
|
3004
|
+
if (!live) continue;
|
|
3005
|
+
seenTables.add(tableName);
|
|
3006
|
+
const uniques = (live.indexes ?? []).filter((index) => index.unique === true && !index.where);
|
|
3007
|
+
const naturalKey = conflictColumns.filter((column) => column !== tenantColumn);
|
|
3008
|
+
const global = uniques.find((index) => sameColumnSet(index.columns ?? [], naturalKey));
|
|
3009
|
+
const target = `(${conflictColumns.join(", ")})`;
|
|
3010
|
+
if (uniques.some((index) => sameColumnSet(index.columns ?? [], conflictColumns))) {
|
|
3011
|
+
if (global) findings.push({
|
|
3012
|
+
name: `${tableName}.${global.name ?? "(unnamed unique)"}`,
|
|
3013
|
+
status: "warning",
|
|
3014
|
+
message: `Tenant-owned table "${tableName}" (${className}) still carries the legacy unique index on (${naturalKey.join(", ")}) beside the tenant-led ${target}. Until it is dropped, a second tenant cannot store a key another tenant already uses (the save fails with a unique violation; nothing is overwritten).`,
|
|
3015
|
+
recommendation: "Once every running instance is on the release that upserts on the tenant-led key, run `smrt db:migrate --drop-legacy-natural-key` to drop it.",
|
|
3016
|
+
details: {
|
|
3017
|
+
className,
|
|
3018
|
+
tableName,
|
|
3019
|
+
conflictColumns,
|
|
3020
|
+
liveIndex: global.name,
|
|
3021
|
+
kind: "legacy_global_unique"
|
|
3022
|
+
}
|
|
3023
|
+
});
|
|
3024
|
+
continue;
|
|
3025
|
+
}
|
|
3026
|
+
if (global) findings.push({
|
|
3027
|
+
name: `${tableName}.${global.name ?? "(unnamed unique)"}`,
|
|
3028
|
+
status: "error",
|
|
3029
|
+
message: `Tenant-owned table "${tableName}" (${className}) has a unique index on (${naturalKey.join(", ")}) shared by every tenant, but the model upserts on ${target}. A second tenant cannot store a key another tenant already uses; saves that collide are refused (TENANT_ISOLATION_VIOLATION), and creates can fail outright until the index matches the upsert target.`,
|
|
3030
|
+
recommendation: "Run `smrt db:migrate` BEFORE deploying the release that upserts on the tenant-led key: it builds the tenant-inclusive unique under its own name (a superset, so it cannot fail on existing rows) and keeps this one, so old code (ON CONFLICT on the global key) and new code (ON CONFLICT on the tenant-led key) both keep working. After the rollout, `smrt db:migrate --drop-legacy-natural-key` drops the legacy index.",
|
|
3031
|
+
details: {
|
|
3032
|
+
className,
|
|
3033
|
+
tableName,
|
|
3034
|
+
conflictColumns,
|
|
3035
|
+
liveIndex: global.name,
|
|
3036
|
+
kind: "global_unique"
|
|
3037
|
+
}
|
|
3038
|
+
});
|
|
3039
|
+
else findings.push({
|
|
3040
|
+
name: `${tableName}.${target}`,
|
|
3041
|
+
status: "warning",
|
|
3042
|
+
message: `Tenant-owned table "${tableName}" (${className}) has no unique index on ${target}, the key the model upserts on.`,
|
|
3043
|
+
recommendation: "Run `smrt db:migrate` to create the tenant-inclusive unique index.",
|
|
3044
|
+
details: {
|
|
3045
|
+
className,
|
|
3046
|
+
tableName,
|
|
3047
|
+
conflictColumns,
|
|
3048
|
+
kind: "missing_tenant_unique"
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
return findings;
|
|
3053
|
+
}
|
|
3054
|
+
//#endregion
|
|
2931
3055
|
//#region src/commands/db-status.ts
|
|
2932
3056
|
/**
|
|
2933
3057
|
* db:status Command
|
|
@@ -3148,6 +3272,11 @@ var dbStatusCommand = {
|
|
|
3148
3272
|
description: "Also compare the live schema to the expected shape (incl. _smrt_* system tables and index policy)",
|
|
3149
3273
|
default: false,
|
|
3150
3274
|
short: "p"
|
|
3275
|
+
},
|
|
3276
|
+
"empty-text-as-null": {
|
|
3277
|
+
type: "boolean",
|
|
3278
|
+
description: "Assess the same convergence `db:migrate --empty-text-as-null` would apply: a nullable legacy text column whose only obstacle is empty or whitespace-only text is pending, not blocked. Off by default.",
|
|
3279
|
+
default: false
|
|
3151
3280
|
}
|
|
3152
3281
|
},
|
|
3153
3282
|
handler: async (_args, options) => {
|
|
@@ -3246,16 +3375,16 @@ var dbStatusCommand = {
|
|
|
3246
3375
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
3247
3376
|
let comparer;
|
|
3248
3377
|
if (typeof db.getTableSchema === "function") {
|
|
3249
|
-
comparer = new SchemaComparer(db);
|
|
3378
|
+
comparer = new SchemaComparer(db, { emptyTextAsNull: Boolean(options["empty-text-as-null"]) });
|
|
3250
3379
|
diff = await comparer.compare(manifestSchemas);
|
|
3251
3380
|
status.drift = summarizeSchemaDiff(diff);
|
|
3252
3381
|
status.notes = summarizeSchemaNotes(diff);
|
|
3253
|
-
status.preconditions = await checkTenantIdUuidPreconditions({
|
|
3382
|
+
status.preconditions = [...await checkTenantIdUuidPreconditions({
|
|
3254
3383
|
db,
|
|
3255
3384
|
dbType,
|
|
3256
3385
|
dbUrl,
|
|
3257
3386
|
manifestSchemas
|
|
3258
|
-
});
|
|
3387
|
+
}), ...checkTenantNaturalKeyUniques(comparer.getLiveSchemaSnapshot())];
|
|
3259
3388
|
status.failedMigrations = summarizeFailedMigrations(failed, getUnresolvedGeneratedMigrationNames(diff.changes));
|
|
3260
3389
|
if (options.parity) try {
|
|
3261
3390
|
status.parity = await checkLiveSchemaParity({
|
|
@@ -4139,14 +4268,14 @@ var docsCommands = {
|
|
|
4139
4268
|
* This replaces per-agent export commands (praeco export, caelus export, etc.)
|
|
4140
4269
|
* with a single, schema-driven export that respects field-level export annotations.
|
|
4141
4270
|
*/
|
|
4142
|
-
function toSnakeCase$
|
|
4271
|
+
function toSnakeCase$2(str) {
|
|
4143
4272
|
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
4144
4273
|
}
|
|
4145
4274
|
function toColumnName(fieldName) {
|
|
4146
4275
|
const dotIndex = fieldName.indexOf(".");
|
|
4147
4276
|
const baseFieldName = dotIndex >= 0 ? fieldName.substring(0, dotIndex) : fieldName;
|
|
4148
4277
|
const jsonPath = dotIndex >= 0 ? fieldName.substring(dotIndex) : "";
|
|
4149
|
-
return `${baseFieldName.startsWith("_") ? `_${toSnakeCase$
|
|
4278
|
+
return `${baseFieldName.startsWith("_") ? `_${toSnakeCase$2(baseFieldName.slice(1))}` : toSnakeCase$2(baseFieldName)}${jsonPath}`;
|
|
4150
4279
|
}
|
|
4151
4280
|
function assertSafeIdentifier(identifier, label) {
|
|
4152
4281
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$/.test(identifier)) throw new Error(`Invalid ${label}: ${identifier}`);
|
|
@@ -6948,7 +7077,7 @@ var dbMaterializeTenantHierarchyCommand = {
|
|
|
6948
7077
|
if (!config.database?.url || config.database.url === ":memory:") throw new Error("Database configuration required for db:materialize-tenant-hierarchy. Configure database.url in smrt.config.ts (or DATABASE_URL).");
|
|
6949
7078
|
const dbType = config.database.type || "sqlite";
|
|
6950
7079
|
const { getDatabase } = await import("@happyvertical/sql");
|
|
6951
|
-
const { materializeTenantHierarchy, TenantHierarchyMaterializationError } = await import("./dist-
|
|
7080
|
+
const { materializeTenantHierarchy, TenantHierarchyMaterializationError } = await import("./dist-vJJL0WMc.js");
|
|
6952
7081
|
db = await getDatabase({
|
|
6953
7082
|
type: dbType,
|
|
6954
7083
|
url: config.database.url
|
|
@@ -7157,7 +7286,7 @@ var dbMigrateLedgerAccountsCommand = {
|
|
|
7157
7286
|
if (dbType !== "postgres") throw new Error("db:migrate-ledger-accounts currently supports PostgreSQL only; no changes were made. See the smrt-ledgers README for SQLite.");
|
|
7158
7287
|
await autoDiscoverAndLoad();
|
|
7159
7288
|
const { getDatabase } = await import("@happyvertical/sql");
|
|
7160
|
-
const { migrateLedgerAccountsTable, planLedgerAccountsTableMove } = await import("./dist-
|
|
7289
|
+
const { migrateLedgerAccountsTable, planLedgerAccountsTableMove } = await import("./dist-Dm4q4uB4.js");
|
|
7161
7290
|
db = await getDatabase({
|
|
7162
7291
|
type: dbType,
|
|
7163
7292
|
url: config.database.url
|
|
@@ -9876,7 +10005,7 @@ export default testManifest;
|
|
|
9876
10005
|
}
|
|
9877
10006
|
}
|
|
9878
10007
|
try {
|
|
9879
|
-
const { resolveDataPath, discoverJsonFiles, JsonDatabaseValidator, displayValidationResults } = await import("./json-validator-
|
|
10008
|
+
const { resolveDataPath, discoverJsonFiles, JsonDatabaseValidator, displayValidationResults } = await import("./json-validator-BiAdsE1O.js");
|
|
9880
10009
|
const dataPath = await resolveDataPath(options.data);
|
|
9881
10010
|
if (!dataPath) {
|
|
9882
10011
|
console.error("\n❌ Could not find data directory");
|
|
@@ -9998,6 +10127,11 @@ export default testManifest;
|
|
|
9998
10127
|
type: "string",
|
|
9999
10128
|
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before converting them to timestamptz. Exact value required: UTC; omitted by default."
|
|
10000
10129
|
},
|
|
10130
|
+
"empty-text-as-null": {
|
|
10131
|
+
type: "boolean",
|
|
10132
|
+
description: "Store empty or whitespace-only text as NULL when converging a nullable legacy text column to a typed column (timestamp, JSON, integer). Any other value that does not convert still blocks, and NOT NULL columns are never changed. Off by default.",
|
|
10133
|
+
default: false
|
|
10134
|
+
},
|
|
10001
10135
|
force: {
|
|
10002
10136
|
type: "boolean",
|
|
10003
10137
|
description: "Force re-apply even if already applied (skip checksum validation)",
|
|
@@ -10023,6 +10157,11 @@ export default testManifest;
|
|
|
10023
10157
|
description: "Drop orphan indexes (in DB but not in manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default.",
|
|
10024
10158
|
default: false
|
|
10025
10159
|
},
|
|
10160
|
+
"drop-legacy-natural-key": {
|
|
10161
|
+
type: "boolean",
|
|
10162
|
+
description: "Drop the legacy global (slug, context) unique index a tenant-led natural key superseded on a table that is tenant-owned by its tenantId field. Off by default: db:migrate builds the tenant-led index beside it so code still on the previous release keeps upserting. Pass this once every instance runs the release that upserts on the tenant-led key.",
|
|
10163
|
+
default: false
|
|
10164
|
+
},
|
|
10026
10165
|
"drop-columns": {
|
|
10027
10166
|
type: "boolean",
|
|
10028
10167
|
description: "DESTRUCTIVE: drop orphan columns (in DB but not in manifest). Off by default; orphans are always reported.",
|
|
@@ -10171,8 +10310,10 @@ export default testManifest;
|
|
|
10171
10310
|
const diff = await new SchemaComparer(db, {
|
|
10172
10311
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
10173
10312
|
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
10313
|
+
dropLegacyNaturalKey: Boolean(options["drop-legacy-natural-key"]),
|
|
10174
10314
|
relaxColumns: Boolean(options["relax-columns"]),
|
|
10175
|
-
postgresTimestampMigration
|
|
10315
|
+
postgresTimestampMigration,
|
|
10316
|
+
emptyTextAsNull: Boolean(options["empty-text-as-null"])
|
|
10176
10317
|
}).compare(manifestSchemas);
|
|
10177
10318
|
const engine = tracker.getEngine();
|
|
10178
10319
|
const tablePlan = planForeignKeyCreation(diff.added_tables, engine);
|
|
@@ -10255,6 +10396,12 @@ export default testManifest;
|
|
|
10255
10396
|
pendingOrphanDispositions.length = 0;
|
|
10256
10397
|
pendingOrphanDispositions.push(...stillPending);
|
|
10257
10398
|
}
|
|
10399
|
+
const destructiveForeignKeys = migrations.filter((migration) => migration.type === "add_foreign_key" && migration.advisory?.severity === "warning" && Boolean(migration.sql || migration.sqlStatements?.length));
|
|
10400
|
+
if (destructiveForeignKeys.length > 0) {
|
|
10401
|
+
console.log(`\n⚠️ ${destructiveForeignKeys.length} foreign-key change(s) make deletes DESTRUCTIVE:`);
|
|
10402
|
+
for (const migration of destructiveForeignKeys) console.log(` ${migration.advisory?.message}`);
|
|
10403
|
+
console.log();
|
|
10404
|
+
}
|
|
10258
10405
|
const preForeignKeyMigrations = migrations.filter((migration) => migration.phase === "pre_foreign_key");
|
|
10259
10406
|
const remainingMigrations = migrations.filter((migration) => migration.phase !== "pre_foreign_key");
|
|
10260
10407
|
assertForceMigrationTargetsExist(forceSelection.forceMigrations, [
|
|
@@ -10407,7 +10554,7 @@ export default testManifest;
|
|
|
10407
10554
|
actionDesc = `Dropped column ${migration.tableName}.${migration.columnName}`;
|
|
10408
10555
|
} else if (migration.type === "type_upgrade" && migration.column) {
|
|
10409
10556
|
migrationSql = migration.sql || "";
|
|
10410
|
-
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}`;
|
|
10557
|
+
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}${migration.note ? ` (${migration.note})` : ""}`;
|
|
10411
10558
|
} else if (migration.type === "add_index" && migration.index) {
|
|
10412
10559
|
migrationSql = migration.sql || "";
|
|
10413
10560
|
actionDesc = `Created index ${migration.index.name} on ${migration.tableName}`;
|
|
@@ -10416,7 +10563,7 @@ export default testManifest;
|
|
|
10416
10563
|
actionDesc = `Dropped index ${migration.indexName} on ${migration.tableName}`;
|
|
10417
10564
|
} else if (migration.type === "add_foreign_key") {
|
|
10418
10565
|
migrationSql = migration.sql || "";
|
|
10419
|
-
actionDesc = `Added foreign-key constraint on ${migration.tableName}`;
|
|
10566
|
+
actionDesc = migration.note ? `Replaced foreign-key constraint on ${migration.tableName} (${migration.note})` : `Added foreign-key constraint on ${migration.tableName}`;
|
|
10420
10567
|
} else if (migration.type === "drop_foreign_key") {
|
|
10421
10568
|
migrationSql = migration.sql || "";
|
|
10422
10569
|
actionDesc = `Dropped foreign-key constraint on ${migration.tableName}`;
|