@objectstack/metadata 17.2.0 → 17.3.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.
@@ -27,23 +27,37 @@ __export(migrations_exports, {
27
27
  });
28
28
  module.exports = __toCommonJS(migrations_exports);
29
29
 
30
+ // src/migrations/driver-exec.ts
31
+ function resolveDriverExec(driver) {
32
+ const candidate = driver;
33
+ if (!candidate) return void 0;
34
+ if (typeof candidate.execute === "function") {
35
+ return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);
36
+ }
37
+ if (typeof candidate.raw === "function") {
38
+ return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);
39
+ }
40
+ return void 0;
41
+ }
42
+ function driverExecRefusal(helper) {
43
+ return `${helper}: driver must expose an .execute(sql, bindings?) or .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) exposes .execute(), as does its SqliteWasmDriver subclass; cloud-side TursoDriver also conforms.`;
44
+ }
45
+
30
46
  // src/migrations/migrate-env-id-to-project-id.ts
31
47
  var AFFECTED_TABLES = [
32
48
  "sys_metadata",
33
49
  "sys_metadata_history"
34
50
  ];
35
51
  async function migrateEnvIdToProjectId(driver) {
36
- const driverAny = driver;
37
- if (typeof driverAny.raw !== "function") {
38
- throw new Error(
39
- "migrateEnvIdToProjectId: driver must expose a .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms."
40
- );
52
+ const exec = resolveDriverExec(driver);
53
+ if (!exec) {
54
+ throw new Error(driverExecRefusal("migrateEnvIdToProjectId"));
41
55
  }
42
56
  const results = [];
43
57
  for (const table of AFFECTED_TABLES) {
44
58
  try {
45
- const hasColumn = await _columnExists(driverAny, table, "env_id");
46
- const alreadyMigrated = await _columnExists(driverAny, table, "project_id");
59
+ const hasColumn = await _columnExists(exec, table, "env_id");
60
+ const alreadyMigrated = await _columnExists(exec, table, "project_id");
47
61
  if (alreadyMigrated && !hasColumn) {
48
62
  results.push({ table, status: "already_done" });
49
63
  continue;
@@ -52,7 +66,7 @@ async function migrateEnvIdToProjectId(driver) {
52
66
  results.push({ table, status: "table_missing" });
53
67
  continue;
54
68
  }
55
- await driverAny.raw(`ALTER TABLE "${table}" RENAME COLUMN env_id TO project_id`);
69
+ await exec(`ALTER TABLE "${table}" RENAME COLUMN env_id TO project_id`);
56
70
  results.push({ table, status: "renamed" });
57
71
  } catch (err) {
58
72
  results.push({ table, status: "error", error: err?.message ?? String(err) });
@@ -60,14 +74,14 @@ async function migrateEnvIdToProjectId(driver) {
60
74
  }
61
75
  return results;
62
76
  }
63
- async function _columnExists(driver, table, column) {
77
+ async function _columnExists(exec, table, column) {
64
78
  try {
65
- const rows = await driver.raw(`PRAGMA table_info("${table}")`);
79
+ const rows = await exec(`PRAGMA table_info("${table}")`);
66
80
  if (Array.isArray(rows) && rows.length > 0) {
67
81
  const list2 = Array.isArray(rows[0]) ? rows[0] : rows;
68
82
  return list2.some((r) => r?.name === column);
69
83
  }
70
- const result = await driver.raw(
84
+ const result = await exec(
71
85
  `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
72
86
  [table, column]
73
87
  );
@@ -79,22 +93,29 @@ async function _columnExists(driver, table, column) {
79
93
  }
80
94
 
81
95
  // src/migrations/migrate-project-id-to-environment-id.ts
82
- var AFFECTED_TABLES2 = [
83
- "sys_metadata",
84
- "sys_metadata_history"
85
- ];
96
+ var import_metadata_core = require("@objectstack/metadata-core");
97
+ var SOURCE_COLUMN = "project_id";
98
+ var TARGET_COLUMN = "environment_id";
99
+ var CANDIDATE_OBJECTS = [import_metadata_core.SysMetadataObject, import_metadata_core.SysMetadataHistoryObject];
100
+ function declaresColumn(object, column) {
101
+ return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);
102
+ }
103
+ var CANDIDATE_TABLES = CANDIDATE_OBJECTS.map((o) => o.name);
104
+ var AFFECTED_TABLES2 = CANDIDATE_OBJECTS.filter((o) => declaresColumn(o, TARGET_COLUMN)).map((o) => o.name);
86
105
  async function migrateProjectIdToEnvironmentId(driver) {
87
- const driverAny = driver;
88
- if (typeof driverAny.raw !== "function") {
89
- throw new Error(
90
- "migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms."
91
- );
106
+ const exec = resolveDriverExec(driver);
107
+ if (!exec) {
108
+ throw new Error(driverExecRefusal("migrateProjectIdToEnvironmentId"));
92
109
  }
93
110
  const results = [];
94
- for (const table of AFFECTED_TABLES2) {
111
+ for (const table of CANDIDATE_TABLES) {
112
+ if (!AFFECTED_TABLES2.includes(table)) {
113
+ results.push({ table, status: "skipped_not_declared" });
114
+ continue;
115
+ }
95
116
  try {
96
- const hasColumn = await _columnExists2(driverAny, table, "project_id");
97
- const alreadyMigrated = await _columnExists2(driverAny, table, "environment_id");
117
+ const hasColumn = await _columnExists2(exec, table, SOURCE_COLUMN);
118
+ const alreadyMigrated = await _columnExists2(exec, table, TARGET_COLUMN);
98
119
  if (alreadyMigrated && !hasColumn) {
99
120
  results.push({ table, status: "already_done" });
100
121
  continue;
@@ -103,8 +124,8 @@ async function migrateProjectIdToEnvironmentId(driver) {
103
124
  results.push({ table, status: "table_missing" });
104
125
  continue;
105
126
  }
106
- await driverAny.raw(
107
- `ALTER TABLE "${table}" RENAME COLUMN project_id TO environment_id`
127
+ await exec(
128
+ `ALTER TABLE "${table}" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`
108
129
  );
109
130
  results.push({ table, status: "renamed" });
110
131
  } catch (err) {
@@ -113,14 +134,14 @@ async function migrateProjectIdToEnvironmentId(driver) {
113
134
  }
114
135
  return results;
115
136
  }
116
- async function _columnExists2(driver, table, column) {
137
+ async function _columnExists2(exec, table, column) {
117
138
  try {
118
- const rows = await driver.raw(`PRAGMA table_info("${table}")`);
139
+ const rows = await exec(`PRAGMA table_info("${table}")`);
119
140
  if (Array.isArray(rows) && rows.length > 0) {
120
141
  const list2 = Array.isArray(rows[0]) ? rows[0] : rows;
121
142
  return list2.some((r) => r?.name === column);
122
143
  }
123
- const result = await driver.raw(
144
+ const result = await exec(
124
145
  `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
125
146
  [table, column]
126
147
  );
@@ -140,14 +161,14 @@ var DEPRECATED_TABLES = [
140
161
  "sys_tool"
141
162
  ];
142
163
  async function dropProjectionTables(driver) {
143
- const driverAny = driver;
144
- if (typeof driverAny.raw !== "function") {
145
- throw new Error("dropProjectionTables: driver must expose a raw(sql) method");
164
+ const exec = resolveDriverExec(driver);
165
+ if (!exec) {
166
+ throw new Error(driverExecRefusal("dropProjectionTables"));
146
167
  }
147
168
  const results = [];
148
169
  for (const table of DEPRECATED_TABLES) {
149
170
  try {
150
- await driverAny.raw(`DROP TABLE IF EXISTS ${table}`);
171
+ await exec(`DROP TABLE IF EXISTS ${table}`);
151
172
  results.push({ table, status: "dropped" });
152
173
  } catch (error) {
153
174
  results.push({
@@ -175,33 +196,33 @@ var LEGACY_COLUMNS = [
175
196
  "read_at"
176
197
  ];
177
198
  async function migrateSysNotificationToEvent(opts) {
178
- const driver = opts.driver;
179
199
  const { data } = opts;
180
200
  const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
181
- if (typeof driver?.raw !== "function") {
201
+ const exec = resolveDriverExec(opts.driver);
202
+ if (!exec) {
182
203
  return {
183
204
  status: "error",
184
205
  migrated: 0,
185
- error: "migrateSysNotificationToEvent: driver must expose a .raw(sql, bindings?) method."
206
+ error: driverExecRefusal("migrateSysNotificationToEvent")
186
207
  };
187
208
  }
188
- if (!await columnExists(driver, EVENT_OBJECT, "recipient_id")) {
209
+ if (!await columnExists(exec, EVENT_OBJECT, "recipient_id")) {
189
210
  return { status: "not_applicable", migrated: 0 };
190
211
  }
191
212
  const presentLegacy = [];
192
213
  for (const col of LEGACY_COLUMNS) {
193
- if (await columnExists(driver, EVENT_OBJECT, col)) presentLegacy.push(col);
214
+ if (await columnExists(exec, EVENT_OBJECT, col)) presentLegacy.push(col);
194
215
  }
195
216
  let migrated = 0;
196
217
  try {
197
- const rows = await selectLegacyRows(driver);
218
+ const rows = await selectLegacyRows(exec);
198
219
  if (rows.length === 0) return { status: "already_done", migrated: 0 };
199
220
  for (const row of rows) {
200
221
  const id = String(row.id);
201
222
  const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
202
223
  if (!recipientId) continue;
203
224
  const orgId = row.organization_id != null ? String(row.organization_id) : null;
204
- const createdAt = row.created_at != null ? String(row.created_at) : now();
225
+ const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
205
226
  const title = row.title != null ? String(row.title) : row.type != null ? String(row.type) : "Notification";
206
227
  const isRead = row.is_read === true || row.is_read === 1 || row.is_read === "1";
207
228
  const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : "legacy";
@@ -222,7 +243,7 @@ async function migrateSysNotificationToEvent(opts) {
222
243
  user_id: recipientId,
223
244
  channel: "inbox",
224
245
  state: isRead ? "read" : "delivered",
225
- at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
246
+ at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
226
247
  organization_id: orgId,
227
248
  created_at: createdAt
228
249
  });
@@ -243,7 +264,7 @@ async function migrateSysNotificationToEvent(opts) {
243
264
  );
244
265
  if (presentLegacy.length > 0) {
245
266
  const setClause = presentLegacy.map((c) => `"${c}" = NULL`).join(", ");
246
- await driver.raw(`UPDATE "${EVENT_OBJECT}" SET ${setClause} WHERE id = ?`, [id]);
267
+ await exec(`UPDATE "${EVENT_OBJECT}" SET ${setClause} WHERE id = ?`, [id]);
247
268
  }
248
269
  migrated += 1;
249
270
  }
@@ -252,8 +273,13 @@ async function migrateSysNotificationToEvent(opts) {
252
273
  return { status: "error", migrated, error: err?.message ?? String(err) };
253
274
  }
254
275
  }
255
- async function selectLegacyRows(driver) {
256
- const result = await driver.raw(
276
+ function canonicalTimestampText(value) {
277
+ if (typeof value === "string") return value;
278
+ if (value instanceof Date) return value.toISOString();
279
+ return String(value);
280
+ }
281
+ async function selectLegacyRows(exec) {
282
+ const result = await exec(
257
283
  `SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id FROM "${EVENT_OBJECT}" WHERE recipient_id IS NOT NULL`
258
284
  );
259
285
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {
@@ -261,9 +287,9 @@ async function selectLegacyRows(driver) {
261
287
  }
262
288
  return Array.isArray(result) ? result : [];
263
289
  }
264
- async function columnExists(driver, table, column) {
290
+ async function columnExists(exec, table, column) {
265
291
  try {
266
- const rows = await driver.raw(`PRAGMA table_info("${table}")`);
292
+ const rows = await exec(`PRAGMA table_info("${table}")`);
267
293
  const list = Array.isArray(rows) ? Array.isArray(rows[0]) ? rows[0] : rows : [];
268
294
  if (list.length > 0 && list.some((r) => r?.name != null)) {
269
295
  return list.some((r) => r?.name === column);
@@ -271,7 +297,7 @@ async function columnExists(driver, table, column) {
271
297
  } catch {
272
298
  }
273
299
  try {
274
- const result = await driver.raw(
300
+ const result = await exec(
275
301
  `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
276
302
  [table, column]
277
303
  );
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/migrations/index.ts","../../src/migrations/migrate-env-id-to-project-id.ts","../../src/migrations/migrate-project-id-to-environment-id.ts","../../src/migrations/drop-projection-tables.ts","../../src/migrations/migrate-sys-notification-to-event.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/metadata/migrations\n *\n * One-off database migrations for the metadata storage layer.\n */\n\nexport { migrateEnvIdToProjectId, type MigrationResult } from './migrate-env-id-to-project-id.js';\nexport {\n migrateProjectIdToEnvironmentId,\n type ProjectIdToEnvironmentIdResult,\n} from './migrate-project-id-to-environment-id.js';\nexport { dropProjectionTables, type DropProjectionResult } from './drop-projection-tables.js';\n\n/**\n * ⚰️ TOMBSTONE — `addSysMetadataOverlayIndex` / `add-sys-metadata-overlay-index.ts`\n * was REMOVED in #6771. Do not reintroduce a producer for\n * `idx_sys_metadata_overlay_active` in this package.\n *\n * It was the second producer of that ONE index name, and it still spelled the\n * PRE-ADR-0048 key `(type, name, organization_id, environment_id, scope)` —\n * `environment_id` is retired (always NULL on new rows, and SQL UNIQUE treats\n * NULLs as DISTINCT, so the index constrained nothing) and `scope` is not in\n * the current discriminator. Because both producers used `IF NOT EXISTS`,\n * whichever ran first claimed the name and the other silently no-opped.\n *\n * Measured on real SQLite before removal (#6771):\n * - on a normal boot the DECLARED index (metadata-core's\n * `sys-metadata.object.ts`, materialized by `SqlDriver.syncDeclaredIndexes`)\n * already holds the name with the CURRENT key\n * `(type, name, organization_id, package_id)`, so this function was a no-op\n * that nevertheless reported `status: 'created'`;\n * - in the only window where it was NOT a no-op (table present, declared\n * indexes not yet materialized) it installed the RETIRED key, and\n * `syncDeclaredIndexes` — which skips by name — then never repaired it.\n * So it could only ever do nothing or do harm.\n *\n * The two producers that remain are both correctly keyed and deliberate:\n * - `metadata-protocol`'s `ensureMetadataOverlayIndexes` (runtime, raw SQL):\n * the partial, NULL-safe form `(type, name, organization_id,\n * COALESCE(package_id, '')) WHERE state = 'active'`;\n * - the declaration in `metadata-core`'s `sys-metadata.object.ts`: the\n * coarser unrestricted UNIQUE that a driver without that runtime migration\n * gets, as that file's own comment states.\n */\n\n\nexport {\n migrateSysNotificationToEvent,\n type SysNotificationMigrationResult,\n type SysNotificationMigrationOptions,\n} from './migrate-sys-notification-to-event.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: env_id → project_id\n *\n * Renames the `env_id` column to `project_id` on the metadata storage tables:\n * - sys_metadata\n * - sys_metadata_history\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `project_id` already exists, the step is skipped.\n *\n * Usage:\n * import { migrateEnvIdToProjectId } from '@objectstack/metadata/migrations';\n * await migrateEnvIdToProjectId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst AFFECTED_TABLES = [\n 'sys_metadata',\n 'sys_metadata_history',\n] as const;\n\nexport interface MigrationResult {\n table: string;\n status: 'renamed' | 'already_done' | 'table_missing' | 'error';\n error?: string;\n}\n\n/**\n * Rename `env_id` → `project_id` on all metadata tables.\n *\n * @param driver An IDataDriver with access to the target database.\n * Must expose a raw query method: `driver.raw(sql, bindings?)`.\n * @returns Per-table migration results.\n */\nexport async function migrateEnvIdToProjectId(driver: IDataDriver): Promise<MigrationResult[]> {\n const driverAny = driver as any;\n\n if (typeof driverAny.raw !== 'function') {\n throw new Error(\n 'migrateEnvIdToProjectId: driver must expose a .raw(sql, bindings?) method. ' +\n 'SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms.'\n );\n }\n\n const results: MigrationResult[] = [];\n\n for (const table of AFFECTED_TABLES) {\n try {\n // Detect dialect: SQLite uses PRAGMA, others use information_schema.\n const hasColumn = await _columnExists(driverAny, table, 'env_id');\n const alreadyMigrated = await _columnExists(driverAny, table, 'project_id');\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n // Neither column exists — table might not exist yet.\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n // Perform the rename. SQLite ≥ 3.25.0 supports ALTER TABLE RENAME COLUMN.\n await driverAny.raw(`ALTER TABLE \"${table}\" RENAME COLUMN env_id TO project_id`);\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(driver: any, table: string, column: string): Promise<boolean> {\n try {\n // SQLite: PRAGMA table_info returns rows with `name` column.\n const rows: any[] = await driver.raw(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n // knex wraps PRAGMA result; handle both `rows` and `rows[0]` shapes.\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n // Fallback for non-SQLite: query information_schema.\n const result: any[] = await driver.raw(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column]\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: project_id → environment_id\n *\n * Renames the `project_id` column to `environment_id` on the metadata\n * storage tables:\n * - sys_metadata\n * - sys_metadata_history\n *\n * Forward counterpart of {@link migrateEnvIdToProjectId} (which performed the\n * earlier `env_id → project_id` rename). Together they let an operator walk an\n * old schema all the way forward in two steps:\n *\n * migrateEnvIdToProjectId(driver); // env_id → project_id (legacy)\n * migrateProjectIdToEnvironmentId(driver); // project_id → environment_id (v5)\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `environment_id` already exists, the step is\n * skipped.\n *\n * Usage:\n * import { migrateProjectIdToEnvironmentId } from '@objectstack/metadata/migrations';\n * await migrateProjectIdToEnvironmentId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst AFFECTED_TABLES = [\n 'sys_metadata',\n 'sys_metadata_history',\n] as const;\n\nexport interface ProjectIdToEnvironmentIdResult {\n table: string;\n status: 'renamed' | 'already_done' | 'table_missing' | 'error';\n error?: string;\n}\n\n/**\n * Rename `project_id` → `environment_id` on all metadata tables.\n *\n * @param driver An IDataDriver with access to the target database.\n * Must expose a raw query method: `driver.raw(sql, bindings?)`.\n * @returns Per-table migration results.\n */\nexport async function migrateProjectIdToEnvironmentId(\n driver: IDataDriver,\n): Promise<ProjectIdToEnvironmentIdResult[]> {\n const driverAny = driver as any;\n\n if (typeof driverAny.raw !== 'function') {\n throw new Error(\n 'migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. ' +\n 'migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. ' +\n 'SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms.'\n );\n }\n\n const results: ProjectIdToEnvironmentIdResult[] = [];\n\n for (const table of AFFECTED_TABLES) {\n try {\n const hasColumn = await _columnExists(driverAny, table, 'project_id');\n const alreadyMigrated = await _columnExists(driverAny, table, 'environment_id');\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n await driverAny.raw(\n `ALTER TABLE \"${table}\" RENAME COLUMN project_id TO environment_id`,\n );\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(driver: any, table: string, column: string): Promise<boolean> {\n try {\n const rows: any[] = await driver.raw(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n const result: any[] = await driver.raw(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: drop deprecated metadata projection tables.\n *\n * In 2026-05 the per-type projection tables (`sys_object` / `sys_view` /\n * `sys_flow` / `sys_agent` / `sys_tool`) and the corresponding\n * `MetadataProjector` were removed (see ADR 0005 addendum). All metadata\n * now lives as JSON inside `sys_metadata` — these projection tables are\n * dead weight on any existing database.\n *\n * This migration drops them if present. It is idempotent and safe to run\n * on databases that never had them (the `DROP TABLE IF EXISTS` is a no-op).\n *\n * Usage:\n * import { dropProjectionTables } from '@objectstack/metadata/migrations';\n * await dropProjectionTables(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst DEPRECATED_TABLES = [\n 'sys_object',\n 'sys_view',\n 'sys_flow',\n 'sys_agent',\n 'sys_tool',\n] as const;\n\nexport interface DropProjectionResult {\n table: string;\n status: 'dropped' | 'not_present' | 'error';\n error?: string;\n}\n\n/**\n * Drop the deprecated per-type metadata projection tables.\n *\n * @param driver An `IDataDriver` with `driver.raw(sql, bindings?)` access.\n * @returns Per-table results.\n */\nexport async function dropProjectionTables(driver: IDataDriver): Promise<DropProjectionResult[]> {\n const driverAny = driver as any;\n if (typeof driverAny.raw !== 'function') {\n throw new Error('dropProjectionTables: driver must expose a raw(sql) method');\n }\n\n const results: DropProjectionResult[] = [];\n for (const table of DEPRECATED_TABLES) {\n try {\n await driverAny.raw(`DROP TABLE IF EXISTS ${table}`);\n results.push({ table, status: 'dropped' });\n } catch (error) {\n results.push({\n table,\n status: 'error',\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n return results;\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: sys_notification (per-user inbox) → notification event (ADR-0030)\n *\n * ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2\n * *event* (one row per `emit`). This migration preserves users' existing bell\n * notifications across the cut-over by splitting each legacy row into the new\n * layered model:\n *\n * legacy sys_notification row (recipient_id, type, title, body, url,\n * actor_name, is_read, read_at, …)\n * │\n * ├─► sys_inbox_message (L5 in-app materialization, keyed by user)\n * ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered')\n * └─► the sys_notification row itself is rewritten to the event shape\n * (topic ← type, payload ← {title,body,url,actor_name}) and its legacy\n * inbox columns are cleared.\n *\n * Idempotent: it acts only on rows that still carry the legacy shape\n * (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy\n * columns were never present (a fresh install created directly in the new\n * shape) — it reports `not_applicable`.\n *\n * Usage:\n * import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations';\n * await migrateSysNotificationToEvent({ driver, data });\n *\n * `driver` provides raw access to read legacy columns the re-modeled schema no\n * longer projects and to clear them; `data` (IDataEngine) performs the\n * structured inbox/receipt writes and the event rewrite so ids, JSON fields and\n * tenant stamping are handled uniformly across drivers.\n */\n\nimport type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';\n\nconst EVENT_OBJECT = 'sys_notification';\nconst INBOX_OBJECT = 'sys_inbox_message';\nconst RECEIPT_OBJECT = 'sys_notification_receipt';\n\n/** Legacy inbox columns cleared once a row is rewritten to the event shape. */\nconst LEGACY_COLUMNS = [\n 'recipient_id',\n 'type',\n 'title',\n 'body',\n 'url',\n 'actor_name',\n 'is_read',\n 'read_at',\n] as const;\n\nexport interface SysNotificationMigrationResult {\n status: 'migrated' | 'already_done' | 'not_applicable' | 'error';\n /** Number of legacy rows split into inbox + receipt + event. */\n migrated: number;\n error?: string;\n}\n\nexport interface SysNotificationMigrationOptions {\n driver: IDataDriver;\n data: IDataEngine;\n /** Defaults to `() => new Date().toISOString()`. */\n now?(): string;\n}\n\nexport async function migrateSysNotificationToEvent(\n opts: SysNotificationMigrationOptions,\n): Promise<SysNotificationMigrationResult> {\n const driver = opts.driver as any;\n const { data } = opts;\n const now = opts.now ?? (() => new Date().toISOString());\n\n if (typeof driver?.raw !== 'function') {\n return {\n status: 'error',\n migrated: 0,\n error: 'migrateSysNotificationToEvent: driver must expose a .raw(sql, bindings?) method.',\n };\n }\n\n // No legacy `recipient_id` column → the table never held the inbox shape.\n if (!(await columnExists(driver, EVENT_OBJECT, 'recipient_id'))) {\n return { status: 'not_applicable', migrated: 0 };\n }\n\n // Only null-out columns that actually exist on this deployment.\n const presentLegacy: string[] = [];\n for (const col of LEGACY_COLUMNS) {\n if (await columnExists(driver, EVENT_OBJECT, col)) presentLegacy.push(col);\n }\n\n let migrated = 0;\n try {\n const rows = await selectLegacyRows(driver);\n if (rows.length === 0) return { status: 'already_done', migrated: 0 };\n\n for (const row of rows) {\n const id = String(row.id);\n const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;\n if (!recipientId) continue; // defensive — guarded by the SELECT filter\n const orgId = row.organization_id != null ? String(row.organization_id) : null;\n const createdAt = row.created_at != null ? String(row.created_at) : now();\n const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');\n const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';\n // One topic for both the inbox row and the rewritten event, so the\n // materialization and its L2 event never disagree (empty/null legacy\n // `type` → 'legacy').\n const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : 'legacy';\n\n // L5 in-app materialization.\n await data.insert(INBOX_OBJECT, {\n user_id: recipientId,\n notification_id: id,\n topic: eventTopic,\n title,\n body_md: row.body ?? null,\n severity: 'info',\n action_url: row.url ?? null,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // L5 receipt (read-state spine).\n await data.insert(RECEIPT_OBJECT, {\n notification_id: id,\n delivery_id: null,\n user_id: recipientId,\n channel: 'inbox',\n state: isRead ? 'read' : 'delivered',\n at: isRead && row.read_at != null ? String(row.read_at) : createdAt,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // Rewrite the row itself to the L2 event shape (engine handles JSON).\n await data.update(\n EVENT_OBJECT,\n {\n id,\n topic: eventTopic,\n severity: 'info',\n payload: {\n title: row.title ?? null,\n body: row.body ?? null,\n url: row.url ?? null,\n actorName: row.actor_name ?? null,\n },\n },\n { where: { id } },\n );\n\n // Clear the legacy inbox columns so the row no longer matches the\n // migration filter (idempotency) and carries no stale recipient.\n if (presentLegacy.length > 0) {\n const setClause = presentLegacy.map((c) => `\"${c}\" = NULL`).join(', ');\n await driver.raw(`UPDATE \"${EVENT_OBJECT}\" SET ${setClause} WHERE id = ?`, [id]);\n }\n\n migrated += 1;\n }\n\n return { status: 'migrated', migrated };\n } catch (err: any) {\n return { status: 'error', migrated, error: err?.message ?? String(err) };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function selectLegacyRows(driver: any): Promise<any[]> {\n const result: any[] = await driver.raw(\n `SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +\n `FROM \"${EVENT_OBJECT}\" WHERE recipient_id IS NOT NULL`,\n );\n // knex wraps some results as `[rows]`; normalize both shapes.\n if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {\n return result[0];\n }\n return Array.isArray(result) ? result : [];\n}\n\nasync function columnExists(driver: any, table: string, column: string): Promise<boolean> {\n // SQLite path: PRAGMA table_info. On Postgres/others this raises a syntax\n // error — swallow it *locally* and fall through to information_schema (the\n // outer-catch version of this would never reach the fallback, making the\n // migration silently no-op on every non-SQLite DB).\n try {\n const rows: any = await driver.raw(`PRAGMA table_info(\"${table}\")`);\n const list: any[] = Array.isArray(rows)\n ? (Array.isArray(rows[0]) ? rows[0] : rows)\n : [];\n if (list.length > 0 && list.some((r: any) => r?.name != null)) {\n return list.some((r: any) => r?.name === column);\n }\n } catch {\n /* not SQLite — fall through to information_schema */\n }\n // Postgres / others.\n try {\n const result: any = await driver.raw(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result)\n ? (Array.isArray(result[0]) ? result[0] : result)\n : [];\n return list.length > 0;\n } catch {\n return false;\n }\n}\n"],"mappings":"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","names":["list","AFFECTED_TABLES","_columnExists","list"]}
1
+ {"version":3,"sources":["../../src/migrations/index.ts","../../src/migrations/driver-exec.ts","../../src/migrations/migrate-env-id-to-project-id.ts","../../src/migrations/migrate-project-id-to-environment-id.ts","../../src/migrations/drop-projection-tables.ts","../../src/migrations/migrate-sys-notification-to-event.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/metadata/migrations\n *\n * One-off database migrations for the metadata storage layer.\n */\n\nexport { migrateEnvIdToProjectId, type MigrationResult } from './migrate-env-id-to-project-id.js';\nexport {\n migrateProjectIdToEnvironmentId,\n type ProjectIdToEnvironmentIdResult,\n} from './migrate-project-id-to-environment-id.js';\nexport { dropProjectionTables, type DropProjectionResult } from './drop-projection-tables.js';\n\n/**\n * ⚰️ TOMBSTONE — `addSysMetadataOverlayIndex` / `add-sys-metadata-overlay-index.ts`\n * was REMOVED in #6771. Do not reintroduce a producer for\n * `idx_sys_metadata_overlay_active` in this package.\n *\n * It was the second producer of that ONE index name, and it still spelled the\n * PRE-ADR-0048 key `(type, name, organization_id, environment_id, scope)` —\n * `environment_id` is retired (always NULL on new rows, and SQL UNIQUE treats\n * NULLs as DISTINCT, so the index constrained nothing) and `scope` is not in\n * the current discriminator. Because both producers used `IF NOT EXISTS`,\n * whichever ran first claimed the name and the other silently no-opped.\n *\n * Measured on real SQLite before removal (#6771):\n * - on a normal boot the DECLARED index (metadata-core's\n * `sys-metadata.object.ts`, materialized by `SqlDriver.syncDeclaredIndexes`)\n * already holds the name with the CURRENT key\n * `(type, name, organization_id, package_id)`, so this function was a no-op\n * that nevertheless reported `status: 'created'`;\n * - in the only window where it was NOT a no-op (table present, declared\n * indexes not yet materialized) it installed the RETIRED key, and\n * `syncDeclaredIndexes` — which skips by name — then never repaired it.\n * So it could only ever do nothing or do harm.\n *\n * The two producers that remain are both correctly keyed and deliberate:\n * - `metadata-protocol`'s `ensureMetadataOverlayIndexes` (runtime, raw SQL):\n * the partial, NULL-safe form `(type, name, organization_id,\n * COALESCE(package_id, '')) WHERE state = 'active'`;\n * - the declaration in `metadata-core`'s `sys-metadata.object.ts`: the\n * coarser unrestricted UNIQUE that a driver without that runtime migration\n * gets, as that file's own comment states.\n */\n\n\nexport {\n migrateSysNotificationToEvent,\n type SysNotificationMigrationResult,\n type SysNotificationMigrationOptions,\n} from './migrate-sys-notification-to-event.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * How the migrations in this directory obtain a raw-SQL entry point.\n *\n * Every helper here used to guard on — and drive through — `driver.raw(sql,\n * bindings?)`. **No data driver in this repo defines `raw`.** Measured on\n * `origin/main`, the only `raw(` member anywhere outside a test double is\n * `packages/verify/src/harness.ts`, an HTTP harness whose signature is\n * `(path, init)`. `SqlDriver` keeps its knex handle `protected`, so\n * `driver.raw` is `undefined` there too, and `SqliteWasmDriver` inherits that.\n * The result was a published, operator-documented migration path that refused\n * every driver the platform ships — quietly, because\n * `migrateSysNotificationToEvent` *returns* `{ status: 'error' }` rather than\n * throwing, and the message blamed the operator's driver instead of saying the\n * migration did not run.\n *\n * ## Why `execute` is tried FIRST\n *\n * `IDataDriver` (`@objectstack/spec/contracts`, `data-driver.ts`) declares\n *\n * ```ts\n * execute(command: unknown, parameters?: unknown[], options?: DriverOptions): Promise<unknown>;\n * ```\n *\n * — **non-optional**, with bound parameters as the second POSITIONAL argument,\n * which is the exact shape `raw(sql, bindings?)` was being called in. `raw` has\n * never appeared on that interface. So `execute` is not merely the surface the\n * shipped drivers happen to have; it is the only raw-execution surface the\n * contract guarantees at all, and a driver that satisfies `IDataDriver` always\n * has it. Trying it first is therefore the order that matches the declaration.\n *\n * ⚠️ `IDataEngine.execute?(command, options?)` (`data-engine.ts`) is a DIFFERENT\n * member on a different interface — its second parameter is an options bag, not\n * bindings. These helpers take an `IDataDriver`, so `data-driver.ts` governs.\n * Do not reason about this call from the engine declaration.\n *\n * ## Prior art, and why the order had to be chosen rather than copied\n *\n * `packages/metadata-protocol/src/migrations/` already resolves both surfaces\n * instead of assuming one — twice, and **in opposite orders**:\n * `partial-index-probe.ts` tries `raw` first, `seed-tenancy-backfill.ts` tries\n * `execute` first. `metadata-protocol/src/protocol.ts` (`ensureOverlayIndex`)\n * is a third, raw-first. One operation with three implementations and two\n * behaviours resolves to the declaration-bound side, so this directory adopts\n * `execute`-first uniformly across all four of its members.\n *\n * `raw` is kept as a fallback rather than dropped: nothing in this repo defines\n * it, but a host or a third-party driver may, and removing a surface that\n * currently works is not what this repair is for. The refusal below therefore\n * fires only for a driver that has NEITHER.\n *\n * ## Known limitation, deliberately not papered over here\n *\n * Two shipped drivers satisfy `typeof driver.execute === 'function'` without\n * being able to run SQL: `MemoryDriver.execute` logs a warning and returns\n * `null` for every command, and `MongoDbDriver.execute` returns a string\n * command back verbatim. Both are selected by the probe below and then answer\n * every column probe with \"absent\", so a migration reports `not_applicable` /\n * `table_missing` instead of refusing. `IDataDriver` exposes no capability flag\n * that would separate \"implements the escape hatch\" from \"can run SQL\"\n * (`DriverCapabilities` has no such member), so distinguishing them is a\n * contract question, not something to guess at with a driver-name sniff.\n * Filed separately.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\n/**\n * A raw-SQL entry point resolved off a driver. `bindings` are passed\n * positionally, matching `IDataDriver.execute`'s declared `parameters`.\n */\nexport type DriverExec = (sql: string, bindings?: readonly unknown[]) => Promise<any>;\n\n/**\n * Resolve the raw-SQL entry point of `driver`, or `undefined` when it offers\n * neither surface.\n *\n * Callers that must refuse should pair this with {@link driverExecRefusal} so\n * every member of this directory states the same remedy.\n */\nexport function resolveDriverExec(driver: IDataDriver | null | undefined): DriverExec | undefined {\n const candidate = driver as any;\n if (!candidate) return undefined;\n // Declared surface first — see the header.\n if (typeof candidate.execute === 'function') {\n return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);\n }\n if (typeof candidate.raw === 'function') {\n return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);\n }\n return undefined;\n}\n\n/**\n * The single refusal sentence used by every migration in this directory, for a\n * driver that offers neither surface.\n *\n * Assembled in one place because the wording carries pinned properties: the\n * remedy is stated exactly ONCE (a guard here once concatenated its instruction\n * twice), the two sentences stay separated rather than running together, and a\n * conforming driver is named so the operator has something to act on.\n */\nexport function driverExecRefusal(helper: string): string {\n return (\n `${helper}: driver must expose an .execute(sql, bindings?) or .raw(sql, bindings?) method. ` +\n 'SqlDriver (better-sqlite3/knex) exposes .execute(), as does its SqliteWasmDriver subclass; ' +\n 'cloud-side TursoDriver also conforms.'\n );\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: env_id → project_id\n *\n * Renames the `env_id` column to `project_id` on the metadata storage tables:\n * - sys_metadata\n * - sys_metadata_history\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `project_id` already exists, the step is skipped.\n *\n * Usage:\n * import { migrateEnvIdToProjectId } from '@objectstack/metadata/migrations';\n * await migrateEnvIdToProjectId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst AFFECTED_TABLES = [\n 'sys_metadata',\n 'sys_metadata_history',\n] as const;\n\nexport interface MigrationResult {\n table: string;\n status: 'renamed' | 'already_done' | 'table_missing' | 'error';\n error?: string;\n}\n\n/**\n * Rename `env_id` → `project_id` on all metadata tables.\n *\n * @param driver An IDataDriver with access to the target database. Raw SQL is\n * issued through the surface `IDataDriver` declares —\n * `execute(sql, bindings?)` — falling back to\n * `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table migration results.\n */\nexport async function migrateEnvIdToProjectId(driver: IDataDriver): Promise<MigrationResult[]> {\n const exec = resolveDriverExec(driver);\n\n if (!exec) {\n throw new Error(driverExecRefusal('migrateEnvIdToProjectId'));\n }\n\n const results: MigrationResult[] = [];\n\n for (const table of AFFECTED_TABLES) {\n try {\n // Detect dialect: SQLite uses PRAGMA, others use information_schema.\n const hasColumn = await _columnExists(exec, table, 'env_id');\n const alreadyMigrated = await _columnExists(exec, table, 'project_id');\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n // Neither column exists — table might not exist yet.\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n // Perform the rename. SQLite ≥ 3.25.0 supports ALTER TABLE RENAME COLUMN.\n await exec(`ALTER TABLE \"${table}\" RENAME COLUMN env_id TO project_id`);\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n try {\n // SQLite: PRAGMA table_info returns rows with `name` column.\n const rows: any[] = await exec(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n // knex wraps PRAGMA result; handle both `rows` and `rows[0]` shapes.\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n // Fallback for non-SQLite: query information_schema.\n const result: any[] = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column]\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: project_id → environment_id\n *\n * Renames the `project_id` column to `environment_id` on the metadata storage\n * tables — but only on the tables whose CURRENT declaration actually knows\n * `environment_id`.\n *\n * Forward counterpart of {@link migrateEnvIdToProjectId} (which performed the\n * earlier `env_id → project_id` rename). Together they let an operator walk an\n * old schema all the way forward in two steps:\n *\n * migrateEnvIdToProjectId(driver); // env_id → project_id (legacy)\n * migrateProjectIdToEnvironmentId(driver); // project_id → environment_id (v5)\n *\n * ─────────────────────────────────────────────────────────────────────\n * Why the table list is DERIVED and not written out (#13205)\n *\n * This migration is the terminal step of that chain: its target column is\n * the CURRENT declared shape, so \"should this table be renamed?\" is not an\n * independent fact — it is `does this object still declare environment_id?`.\n * Written out by hand, the two drifted apart: `sys_metadata_history` stayed\n * on the list after the branch/project-removal amendment (M1) removed\n * `environment_id` from its declaration, so against a database whose\n * physical `sys_metadata_history` still carried `project_id` this migration\n * renamed it to a column NO declaration knows about — minting exactly the\n * orphan column class the metadata drift audit exists to remove.\n *\n * The old guard could not catch it: the loop gates on `project_id` existing\n * PHYSICALLY (`_columnExists`), which says nothing about the target column\n * being DECLARED. So the list is now computed from the declarations in\n * `@objectstack/metadata-core` (already a dependency of this package — no\n * new edge), and a candidate that does not declare the target column is\n * reported as `skipped_not_declared` rather than dropped silently: an\n * operator reading the result sees the table was considered and why nothing\n * happened, instead of having to guess whether it was forgotten again.\n *\n * ⚠️ The sibling `migrate-env-id-to-project-id.ts` is deliberately NOT\n * changed this way. Its target (`project_id`) is an INTERMEDIATE column that\n * no current declaration carries by design — gating it on today's\n * declarations would disable the chain's first step entirely. The rule\n * \"target must be declared\" is sound only for the terminal migration.\n * ─────────────────────────────────────────────────────────────────────\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `environment_id` already exists, the step is\n * skipped.\n *\n * Usage:\n * import { migrateProjectIdToEnvironmentId } from '@objectstack/metadata/migrations';\n * await migrateProjectIdToEnvironmentId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\nimport { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';\n\n/** The column this migration RENAMES AWAY FROM. */\nconst SOURCE_COLUMN = 'project_id';\n\n/** The column this migration PRODUCES. Must be declared, or the rename mints an orphan. */\nconst TARGET_COLUMN = 'environment_id';\n\n/**\n * Every metadata storage table this migration considers. Membership here says\n * \"this table has, historically, carried the tenancy column\" — whether the\n * rename actually runs is decided by {@link AFFECTED_TABLES} below, from the\n * declaration.\n */\nconst CANDIDATE_OBJECTS = [SysMetadataObject, SysMetadataHistoryObject] as const;\n\nfunction declaresColumn(object: { fields?: Record<string, unknown> }, column: string): boolean {\n return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);\n}\n\n/** Candidate table names, in declaration order. */\nconst CANDIDATE_TABLES: readonly string[] = CANDIDATE_OBJECTS.map((o) => o.name);\n\n/**\n * The tables this migration will actually rename: the candidates whose CURRENT\n * declaration carries {@link TARGET_COLUMN}.\n *\n * Exported for the pin in `migrate-project-id-to-environment-id.test.ts` (not\n * re-exported from `./index.ts` — this is not package surface).\n */\nexport const AFFECTED_TABLES: readonly string[] = CANDIDATE_OBJECTS\n .filter((o) => declaresColumn(o, TARGET_COLUMN))\n .map((o) => o.name);\n\nexport interface ProjectIdToEnvironmentIdResult {\n table: string;\n /**\n * `skipped_not_declared` — the table is a known metadata storage table, but\n * its current declaration has no `environment_id`, so renaming into it\n * would create a column nothing declares. Nothing was executed.\n */\n status: 'renamed' | 'already_done' | 'table_missing' | 'skipped_not_declared' | 'error';\n error?: string;\n}\n\n/**\n * Rename `project_id` → `environment_id` on all metadata tables that still\n * declare `environment_id`.\n *\n * @param driver An IDataDriver with access to the target database. Raw SQL is\n * issued through the surface `IDataDriver` declares —\n * `execute(sql, bindings?)` — falling back to\n * `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table migration results — one entry per candidate table,\n * including the ones skipped for lacking the declared target.\n */\nexport async function migrateProjectIdToEnvironmentId(\n driver: IDataDriver,\n): Promise<ProjectIdToEnvironmentIdResult[]> {\n const exec = resolveDriverExec(driver);\n\n if (!exec) {\n throw new Error(driverExecRefusal('migrateProjectIdToEnvironmentId'));\n }\n\n const results: ProjectIdToEnvironmentIdResult[] = [];\n\n for (const table of CANDIDATE_TABLES) {\n // The declared-target gate, ahead of every physical probe: a table whose\n // declaration lost `environment_id` must never be renamed INTO it, no\n // matter what the physical schema still carries (#13205).\n if (!AFFECTED_TABLES.includes(table)) {\n results.push({ table, status: 'skipped_not_declared' });\n continue;\n }\n\n try {\n const hasColumn = await _columnExists(exec, table, SOURCE_COLUMN);\n const alreadyMigrated = await _columnExists(exec, table, TARGET_COLUMN);\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n await exec(\n `ALTER TABLE \"${table}\" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`,\n );\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n try {\n const rows: any[] = await exec(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n const result: any[] = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: drop deprecated metadata projection tables.\n *\n * In 2026-05 the per-type projection tables (`sys_object` / `sys_view` /\n * `sys_flow` / `sys_agent` / `sys_tool`) and the corresponding\n * `MetadataProjector` were removed (see ADR 0005 addendum). All metadata\n * now lives as JSON inside `sys_metadata` — these projection tables are\n * dead weight on any existing database.\n *\n * This migration drops them if present. It is idempotent and safe to run\n * on databases that never had them (the `DROP TABLE IF EXISTS` is a no-op).\n *\n * Usage:\n * import { dropProjectionTables } from '@objectstack/metadata/migrations';\n * await dropProjectionTables(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst DEPRECATED_TABLES = [\n 'sys_object',\n 'sys_view',\n 'sys_flow',\n 'sys_agent',\n 'sys_tool',\n] as const;\n\nexport interface DropProjectionResult {\n table: string;\n status: 'dropped' | 'not_present' | 'error';\n error?: string;\n}\n\n/**\n * Drop the deprecated per-type metadata projection tables.\n *\n * @param driver An `IDataDriver`. Raw SQL is issued through the surface\n * `IDataDriver` declares — `execute(sql, bindings?)` — falling\n * back to `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table results.\n */\nexport async function dropProjectionTables(driver: IDataDriver): Promise<DropProjectionResult[]> {\n const exec = resolveDriverExec(driver);\n if (!exec) {\n throw new Error(driverExecRefusal('dropProjectionTables'));\n }\n\n const results: DropProjectionResult[] = [];\n for (const table of DEPRECATED_TABLES) {\n try {\n await exec(`DROP TABLE IF EXISTS ${table}`);\n results.push({ table, status: 'dropped' });\n } catch (error) {\n results.push({\n table,\n status: 'error',\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n return results;\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: sys_notification (per-user inbox) → notification event (ADR-0030)\n *\n * ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2\n * *event* (one row per `emit`). This migration preserves users' existing bell\n * notifications across the cut-over by splitting each legacy row into the new\n * layered model:\n *\n * legacy sys_notification row (recipient_id, type, title, body, url,\n * actor_name, is_read, read_at, …)\n * │\n * ├─► sys_inbox_message (L5 in-app materialization, keyed by user)\n * ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered')\n * └─► the sys_notification row itself is rewritten to the event shape\n * (topic ← type, payload ← {title,body,url,actor_name}) and its legacy\n * inbox columns are cleared.\n *\n * Idempotent: it acts only on rows that still carry the legacy shape\n * (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy\n * columns were never present (a fresh install created directly in the new\n * shape) — it reports `not_applicable`.\n *\n * Usage:\n * import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations';\n * await migrateSysNotificationToEvent({ driver, data });\n *\n * `driver` provides raw access to read legacy columns the re-modeled schema no\n * longer projects and to clear them — through the surface `IDataDriver`\n * declares, `execute(sql, bindings?)`, falling back to `raw(sql, bindings?)`\n * (see `./driver-exec.ts`); `data` (IDataEngine) performs the\n * structured inbox/receipt writes and the event rewrite so ids, JSON fields and\n * tenant stamping are handled uniformly across drivers.\n */\n\nimport type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst EVENT_OBJECT = 'sys_notification';\nconst INBOX_OBJECT = 'sys_inbox_message';\nconst RECEIPT_OBJECT = 'sys_notification_receipt';\n\n/** Legacy inbox columns cleared once a row is rewritten to the event shape. */\nconst LEGACY_COLUMNS = [\n 'recipient_id',\n 'type',\n 'title',\n 'body',\n 'url',\n 'actor_name',\n 'is_read',\n 'read_at',\n] as const;\n\nexport interface SysNotificationMigrationResult {\n status: 'migrated' | 'already_done' | 'not_applicable' | 'error';\n /** Number of legacy rows split into inbox + receipt + event. */\n migrated: number;\n error?: string;\n}\n\nexport interface SysNotificationMigrationOptions {\n driver: IDataDriver;\n data: IDataEngine;\n /** Defaults to `() => new Date().toISOString()`. */\n now?(): string;\n}\n\nexport async function migrateSysNotificationToEvent(\n opts: SysNotificationMigrationOptions,\n): Promise<SysNotificationMigrationResult> {\n const { data } = opts;\n const now = opts.now ?? (() => new Date().toISOString());\n\n const exec = resolveDriverExec(opts.driver);\n if (!exec) {\n return {\n status: 'error',\n migrated: 0,\n error: driverExecRefusal('migrateSysNotificationToEvent'),\n };\n }\n\n // No legacy `recipient_id` column → the table never held the inbox shape.\n if (!(await columnExists(exec, EVENT_OBJECT, 'recipient_id'))) {\n return { status: 'not_applicable', migrated: 0 };\n }\n\n // Only null-out columns that actually exist on this deployment.\n const presentLegacy: string[] = [];\n for (const col of LEGACY_COLUMNS) {\n if (await columnExists(exec, EVENT_OBJECT, col)) presentLegacy.push(col);\n }\n\n let migrated = 0;\n try {\n const rows = await selectLegacyRows(exec);\n if (rows.length === 0) return { status: 'already_done', migrated: 0 };\n\n for (const row of rows) {\n const id = String(row.id);\n const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;\n if (!recipientId) continue; // defensive — guarded by the SELECT filter\n const orgId = row.organization_id != null ? String(row.organization_id) : null;\n const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();\n const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');\n const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';\n // One topic for both the inbox row and the rewritten event, so the\n // materialization and its L2 event never disagree (empty/null legacy\n // `type` → 'legacy').\n const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : 'legacy';\n\n // L5 in-app materialization.\n await data.insert(INBOX_OBJECT, {\n user_id: recipientId,\n notification_id: id,\n topic: eventTopic,\n title,\n body_md: row.body ?? null,\n severity: 'info',\n action_url: row.url ?? null,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // L5 receipt (read-state spine).\n await data.insert(RECEIPT_OBJECT, {\n notification_id: id,\n delivery_id: null,\n user_id: recipientId,\n channel: 'inbox',\n state: isRead ? 'read' : 'delivered',\n at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // Rewrite the row itself to the L2 event shape (engine handles JSON).\n await data.update(\n EVENT_OBJECT,\n {\n id,\n topic: eventTopic,\n severity: 'info',\n payload: {\n title: row.title ?? null,\n body: row.body ?? null,\n url: row.url ?? null,\n actorName: row.actor_name ?? null,\n },\n },\n { where: { id } },\n );\n\n // Clear the legacy inbox columns so the row no longer matches the\n // migration filter (idempotency) and carries no stale recipient.\n if (presentLegacy.length > 0) {\n const setClause = presentLegacy.map((c) => `\"${c}\" = NULL`).join(', ');\n await exec(`UPDATE \"${EVENT_OBJECT}\" SET ${setClause} WHERE id = ?`, [id]);\n }\n\n migrated += 1;\n }\n\n return { status: 'migrated', migrated };\n } catch (err: any) {\n return { status: 'error', migrated, error: err?.message ?? String(err) };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * The canonical text spelling of a timestamp read back out of the legacy table.\n *\n * `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the\n * dialect client's own materialisation straight back — that door does not run\n * `formatOutput`, so none of its repairs apply here on any dialect:\n *\n * - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`\n * and no declared-field coercion reaches it; `formatOutput` repairs it only\n * inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over\n * `AUDIT_TIMESTAMP_COLUMNS`).\n * - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is\n * not declared either — it can never enter `datetimeFields`, and it is not\n * an audit column, so no arm of `formatOutput` could reach it even at the\n * record read door.\n *\n * On SQLite both arrive as canonical ISO text and `String()` is the identity —\n * which is why every test in this directory stayed green. On Postgres and\n * MySQL an instant column materialises as a JS `Date`\n * (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;\n * pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and\n * `String(date)` spells\n *\n * Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)\n *\n * — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.\n * This migration is one-way and this value is WRITTEN, so that spelling is what\n * the platform would carry afterwards: either accepted and stored skewed and\n * de-precisioned, or rejected outright, since the trailing zone name is in no\n * dialect's timestamp grammar (#13998).\n *\n * Canonicalising HERE, at the consumer that writes, is deliberate and is the\n * only shape that could also repair an already-migrated deployment (#13973\n * option A). It is not a tolerant alias: `Date` and ISO text are two\n * materialisations of ONE instant, not two spellings of a key. Matches the\n * repo's existing correct form at `metadata-protocol/src/protocol.ts` (the\n * `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a\n * string nor a `Date` keeps its previous `String()` rendering unchanged rather\n * than having a unit guessed for it on a one-way write path.\n */\nfunction canonicalTimestampText(value: unknown): string {\n if (typeof value === 'string') return value;\n if (value instanceof Date) return value.toISOString();\n return String(value);\n}\n\nasync function selectLegacyRows(exec: DriverExec): Promise<any[]> {\n const result: any[] = await exec(\n `SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +\n `FROM \"${EVENT_OBJECT}\" WHERE recipient_id IS NOT NULL`,\n );\n // knex wraps some results as `[rows]`; normalize both shapes.\n if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {\n return result[0];\n }\n return Array.isArray(result) ? result : [];\n}\n\nasync function columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n // SQLite path: PRAGMA table_info. On Postgres/others this raises a syntax\n // error — swallow it *locally* and fall through to information_schema (the\n // outer-catch version of this would never reach the fallback, making the\n // migration silently no-op on every non-SQLite DB).\n try {\n const rows: any = await exec(`PRAGMA table_info(\"${table}\")`);\n const list: any[] = Array.isArray(rows)\n ? (Array.isArray(rows[0]) ? rows[0] : rows)\n : [];\n if (list.length > 0 && list.some((r: any) => r?.name != null)) {\n return list.some((r: any) => r?.name === column);\n }\n } catch {\n /* not SQLite — fall through to information_schema */\n }\n // Postgres / others.\n try {\n const result: any = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result)\n ? (Array.isArray(result[0]) ? result[0] : result)\n : [];\n return list.length > 0;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACiFO,SAAS,kBAAkB,QAAgE;AAC9F,QAAM,YAAY;AAClB,MAAI,CAAC,UAAW,QAAO;AAEvB,MAAI,OAAO,UAAU,YAAY,YAAY;AACzC,WAAO,CAAC,KAAK,aAAa,UAAU,QAAQ,KAAK,WAAW,CAAC,GAAG,QAAQ,IAAI,CAAC,CAAC;AAAA,EAClF;AACA,MAAI,OAAO,UAAU,QAAQ,YAAY;AACrC,WAAO,CAAC,KAAK,aAAa,UAAU,IAAI,KAAK,WAAW,CAAC,GAAG,QAAQ,IAAI,CAAC,CAAC;AAAA,EAC9E;AACA,SAAO;AACX;AAWO,SAAS,kBAAkB,QAAwB;AACtD,SACI,GAAG,MAAM;AAIjB;;;ACpFA,IAAM,kBAAkB;AAAA,EACpB;AAAA,EACA;AACJ;AAiBA,eAAsB,wBAAwB,QAAiD;AAC3F,QAAM,OAAO,kBAAkB,MAAM;AAErC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,yBAAyB,CAAC;AAAA,EAChE;AAEA,QAAM,UAA6B,CAAC;AAEpC,aAAW,SAAS,iBAAiB;AACjC,QAAI;AAEA,YAAM,YAAY,MAAM,cAAc,MAAM,OAAO,QAAQ;AAC3D,YAAM,kBAAkB,MAAM,cAAc,MAAM,OAAO,YAAY;AAErE,UAAI,mBAAmB,CAAC,WAAW;AAC/B,gBAAQ,KAAK,EAAE,OAAO,QAAQ,eAAe,CAAC;AAC9C;AAAA,MACJ;AAEA,UAAI,CAAC,WAAW;AAEZ,gBAAQ,KAAK,EAAE,OAAO,QAAQ,gBAAgB,CAAC;AAC/C;AAAA,MACJ;AAGA,YAAM,KAAK,gBAAgB,KAAK,sCAAsC;AAEtE,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,KAAU;AACf,cAAQ,KAAK,EAAE,OAAO,QAAQ,SAAS,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE,CAAC;AAAA,IAC/E;AAAA,EACJ;AAEA,SAAO;AACX;AAMA,eAAe,cAAc,MAAkB,OAAe,QAAkC;AAC5F,MAAI;AAEA,UAAM,OAAc,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC9D,QAAI,MAAM,QAAQ,IAAI,KAAK,KAAK,SAAS,GAAG;AAExC,YAAMA,QAAc,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI;AACvD,aAAOA,MAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAGA,UAAM,SAAgB,MAAM;AAAA,MACxB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI;AAC3D,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;;;AC9CA,2BAA4D;AAG5D,IAAM,gBAAgB;AAGtB,IAAM,gBAAgB;AAQtB,IAAM,oBAAoB,CAAC,wCAAmB,6CAAwB;AAEtE,SAAS,eAAe,QAA8C,QAAyB;AAC3F,SAAO,OAAO,UAAU,eAAe,KAAK,OAAO,UAAU,CAAC,GAAG,MAAM;AAC3E;AAGA,IAAM,mBAAsC,kBAAkB,IAAI,CAAC,MAAM,EAAE,IAAI;AASxE,IAAMC,mBAAqC,kBAC7C,OAAO,CAAC,MAAM,eAAe,GAAG,aAAa,CAAC,EAC9C,IAAI,CAAC,MAAM,EAAE,IAAI;AAwBtB,eAAsB,gCAClB,QACyC;AACzC,QAAM,OAAO,kBAAkB,MAAM;AAErC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,iCAAiC,CAAC;AAAA,EACxE;AAEA,QAAM,UAA4C,CAAC;AAEnD,aAAW,SAAS,kBAAkB;AAIlC,QAAI,CAACA,iBAAgB,SAAS,KAAK,GAAG;AAClC,cAAQ,KAAK,EAAE,OAAO,QAAQ,uBAAuB,CAAC;AACtD;AAAA,IACJ;AAEA,QAAI;AACA,YAAM,YAAY,MAAMC,eAAc,MAAM,OAAO,aAAa;AAChE,YAAM,kBAAkB,MAAMA,eAAc,MAAM,OAAO,aAAa;AAEtE,UAAI,mBAAmB,CAAC,WAAW;AAC/B,gBAAQ,KAAK,EAAE,OAAO,QAAQ,eAAe,CAAC;AAC9C;AAAA,MACJ;AAEA,UAAI,CAAC,WAAW;AACZ,gBAAQ,KAAK,EAAE,OAAO,QAAQ,gBAAgB,CAAC;AAC/C;AAAA,MACJ;AAEA,YAAM;AAAA,QACF,gBAAgB,KAAK,mBAAmB,aAAa,OAAO,aAAa;AAAA,MAC7E;AAEA,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,KAAU;AACf,cAAQ,KAAK,EAAE,OAAO,QAAQ,SAAS,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE,CAAC;AAAA,IAC/E;AAAA,EACJ;AAEA,SAAO;AACX;AAMA,eAAeA,eAAc,MAAkB,OAAe,QAAkC;AAC5F,MAAI;AACA,UAAM,OAAc,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC9D,QAAI,MAAM,QAAQ,IAAI,KAAK,KAAK,SAAS,GAAG;AACxC,YAAMC,QAAc,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI;AACvD,aAAOA,MAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAEA,UAAM,SAAgB,MAAM;AAAA,MACxB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI;AAC3D,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;;;AClKA,IAAM,oBAAoB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAgBA,eAAsB,qBAAqB,QAAsD;AAC7F,QAAM,OAAO,kBAAkB,MAAM;AACrC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,sBAAsB,CAAC;AAAA,EAC7D;AAEA,QAAM,UAAkC,CAAC;AACzC,aAAW,SAAS,mBAAmB;AACnC,QAAI;AACA,YAAM,KAAK,wBAAwB,KAAK,EAAE;AAC1C,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,OAAO;AACZ,cAAQ,KAAK;AAAA,QACT;AAAA,QACA,QAAQ;AAAA,QACR,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,MAChE,CAAC;AAAA,IACL;AAAA,EACJ;AACA,SAAO;AACX;;;ACzBA,IAAM,eAAe;AACrB,IAAM,eAAe;AACrB,IAAM,iBAAiB;AAGvB,IAAM,iBAAiB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAgBA,eAAsB,8BAClB,MACuC;AACvC,QAAM,EAAE,KAAK,IAAI;AACjB,QAAM,MAAM,KAAK,QAAQ,OAAM,oBAAI,KAAK,GAAE,YAAY;AAEtD,QAAM,OAAO,kBAAkB,KAAK,MAAM;AAC1C,MAAI,CAAC,MAAM;AACP,WAAO;AAAA,MACH,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,OAAO,kBAAkB,+BAA+B;AAAA,IAC5D;AAAA,EACJ;AAGA,MAAI,CAAE,MAAM,aAAa,MAAM,cAAc,cAAc,GAAI;AAC3D,WAAO,EAAE,QAAQ,kBAAkB,UAAU,EAAE;AAAA,EACnD;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,OAAO,gBAAgB;AAC9B,QAAI,MAAM,aAAa,MAAM,cAAc,GAAG,EAAG,eAAc,KAAK,GAAG;AAAA,EAC3E;AAEA,MAAI,WAAW;AACf,MAAI;AACA,UAAM,OAAO,MAAM,iBAAiB,IAAI;AACxC,QAAI,KAAK,WAAW,EAAG,QAAO,EAAE,QAAQ,gBAAgB,UAAU,EAAE;AAEpE,eAAW,OAAO,MAAM;AACpB,YAAM,KAAK,OAAO,IAAI,EAAE;AACxB,YAAM,cAAc,IAAI,gBAAgB,OAAO,OAAO,IAAI,YAAY,IAAI;AAC1E,UAAI,CAAC,YAAa;AAClB,YAAM,QAAQ,IAAI,mBAAmB,OAAO,OAAO,IAAI,eAAe,IAAI;AAC1E,YAAM,YAAY,IAAI,cAAc,OAAO,uBAAuB,IAAI,UAAU,IAAI,IAAI;AACxF,YAAM,QAAQ,IAAI,SAAS,OAAO,OAAO,IAAI,KAAK,IAAK,IAAI,QAAQ,OAAO,OAAO,IAAI,IAAI,IAAI;AAC7F,YAAM,SAAS,IAAI,YAAY,QAAQ,IAAI,YAAY,KAAK,IAAI,YAAY;AAI5E,YAAM,aAAa,IAAI,QAAQ,QAAQ,OAAO,IAAI,IAAI,EAAE,SAAS,IAAI,OAAO,IAAI,IAAI,IAAI;AAGxF,YAAM,KAAK,OAAO,cAAc;AAAA,QAC5B,SAAS;AAAA,QACT,iBAAiB;AAAA,QACjB,OAAO;AAAA,QACP;AAAA,QACA,SAAS,IAAI,QAAQ;AAAA,QACrB,UAAU;AAAA,QACV,YAAY,IAAI,OAAO;AAAA,QACvB,iBAAiB;AAAA,QACjB,YAAY;AAAA,MAChB,CAAC;AAGD,YAAM,KAAK,OAAO,gBAAgB;AAAA,QAC9B,iBAAiB;AAAA,QACjB,aAAa;AAAA,QACb,SAAS;AAAA,QACT,SAAS;AAAA,QACT,OAAO,SAAS,SAAS;AAAA,QACzB,IAAI,UAAU,IAAI,WAAW,OAAO,uBAAuB,IAAI,OAAO,IAAI;AAAA,QAC1E,iBAAiB;AAAA,QACjB,YAAY;AAAA,MAChB,CAAC;AAGD,YAAM,KAAK;AAAA,QACP;AAAA,QACA;AAAA,UACI;AAAA,UACA,OAAO;AAAA,UACP,UAAU;AAAA,UACV,SAAS;AAAA,YACL,OAAO,IAAI,SAAS;AAAA,YACpB,MAAM,IAAI,QAAQ;AAAA,YAClB,KAAK,IAAI,OAAO;AAAA,YAChB,WAAW,IAAI,cAAc;AAAA,UACjC;AAAA,QACJ;AAAA,QACA,EAAE,OAAO,EAAE,GAAG,EAAE;AAAA,MACpB;AAIA,UAAI,cAAc,SAAS,GAAG;AAC1B,cAAM,YAAY,cAAc,IAAI,CAAC,MAAM,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI;AACrE,cAAM,KAAK,WAAW,YAAY,SAAS,SAAS,iBAAiB,CAAC,EAAE,CAAC;AAAA,MAC7E;AAEA,kBAAY;AAAA,IAChB;AAEA,WAAO,EAAE,QAAQ,YAAY,SAAS;AAAA,EAC1C,SAAS,KAAU;AACf,WAAO,EAAE,QAAQ,SAAS,UAAU,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE;AAAA,EAC3E;AACJ;AA8CA,SAAS,uBAAuB,OAAwB;AACpD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,SAAO,OAAO,KAAK;AACvB;AAEA,eAAe,iBAAiB,MAAkC;AAC9D,QAAM,SAAgB,MAAM;AAAA,IACxB,oHACa,YAAY;AAAA,EAC7B;AAEA,MAAI,MAAM,QAAQ,MAAM,KAAK,OAAO,SAAS,KAAK,MAAM,QAAQ,OAAO,CAAC,CAAC,GAAG;AACxE,WAAO,OAAO,CAAC;AAAA,EACnB;AACA,SAAO,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC;AAC7C;AAEA,eAAe,aAAa,MAAkB,OAAe,QAAkC;AAK3F,MAAI;AACA,UAAM,OAAY,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC5D,UAAM,OAAc,MAAM,QAAQ,IAAI,IAC/B,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,OACpC,CAAC;AACP,QAAI,KAAK,SAAS,KAAK,KAAK,KAAK,CAAC,MAAW,GAAG,QAAQ,IAAI,GAAG;AAC3D,aAAO,KAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAAA,EACJ,QAAQ;AAAA,EAER;AAEA,MAAI;AACA,UAAM,SAAc,MAAM;AAAA,MACtB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,MAAM,IACjC,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,SACxC,CAAC;AACP,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;","names":["list","AFFECTED_TABLES","_columnExists","list"]}
@@ -27,8 +27,10 @@ interface MigrationResult {
27
27
  /**
28
28
  * Rename `env_id` → `project_id` on all metadata tables.
29
29
  *
30
- * @param driver An IDataDriver with access to the target database.
31
- * Must expose a raw query method: `driver.raw(sql, bindings?)`.
30
+ * @param driver An IDataDriver with access to the target database. Raw SQL is
31
+ * issued through the surface `IDataDriver` declares
32
+ * `execute(sql, bindings?)` — falling back to
33
+ * `raw(sql, bindings?)`; see `./driver-exec.ts`.
32
34
  * @returns Per-table migration results.
33
35
  */
34
36
  declare function migrateEnvIdToProjectId(driver: IDataDriver): Promise<MigrationResult[]>;
@@ -36,10 +38,9 @@ declare function migrateEnvIdToProjectId(driver: IDataDriver): Promise<Migration
36
38
  /**
37
39
  * Migration: project_id → environment_id
38
40
  *
39
- * Renames the `project_id` column to `environment_id` on the metadata
40
- * storage tables:
41
- * - sys_metadata
42
- * - sys_metadata_history
41
+ * Renames the `project_id` column to `environment_id` on the metadata storage
42
+ * tables — but only on the tables whose CURRENT declaration actually knows
43
+ * `environment_id`.
43
44
  *
44
45
  * Forward counterpart of {@link migrateEnvIdToProjectId} (which performed the
45
46
  * earlier `env_id → project_id` rename). Together they let an operator walk an
@@ -48,6 +49,35 @@ declare function migrateEnvIdToProjectId(driver: IDataDriver): Promise<Migration
48
49
  * migrateEnvIdToProjectId(driver); // env_id → project_id (legacy)
49
50
  * migrateProjectIdToEnvironmentId(driver); // project_id → environment_id (v5)
50
51
  *
52
+ * ─────────────────────────────────────────────────────────────────────
53
+ * Why the table list is DERIVED and not written out (#13205)
54
+ *
55
+ * This migration is the terminal step of that chain: its target column is
56
+ * the CURRENT declared shape, so "should this table be renamed?" is not an
57
+ * independent fact — it is `does this object still declare environment_id?`.
58
+ * Written out by hand, the two drifted apart: `sys_metadata_history` stayed
59
+ * on the list after the branch/project-removal amendment (M1) removed
60
+ * `environment_id` from its declaration, so against a database whose
61
+ * physical `sys_metadata_history` still carried `project_id` this migration
62
+ * renamed it to a column NO declaration knows about — minting exactly the
63
+ * orphan column class the metadata drift audit exists to remove.
64
+ *
65
+ * The old guard could not catch it: the loop gates on `project_id` existing
66
+ * PHYSICALLY (`_columnExists`), which says nothing about the target column
67
+ * being DECLARED. So the list is now computed from the declarations in
68
+ * `@objectstack/metadata-core` (already a dependency of this package — no
69
+ * new edge), and a candidate that does not declare the target column is
70
+ * reported as `skipped_not_declared` rather than dropped silently: an
71
+ * operator reading the result sees the table was considered and why nothing
72
+ * happened, instead of having to guess whether it was forgotten again.
73
+ *
74
+ * ⚠️ The sibling `migrate-env-id-to-project-id.ts` is deliberately NOT
75
+ * changed this way. Its target (`project_id`) is an INTERMEDIATE column that
76
+ * no current declaration carries by design — gating it on today's
77
+ * declarations would disable the chain's first step entirely. The rule
78
+ * "target must be declared" is sound only for the terminal migration.
79
+ * ─────────────────────────────────────────────────────────────────────
80
+ *
51
81
  * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /
52
82
  * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection
53
83
  * pipeline — see ADR 0005 addendum. They are intentionally not included.)
@@ -63,15 +93,24 @@ declare function migrateEnvIdToProjectId(driver: IDataDriver): Promise<Migration
63
93
 
64
94
  interface ProjectIdToEnvironmentIdResult {
65
95
  table: string;
66
- status: 'renamed' | 'already_done' | 'table_missing' | 'error';
96
+ /**
97
+ * `skipped_not_declared` — the table is a known metadata storage table, but
98
+ * its current declaration has no `environment_id`, so renaming into it
99
+ * would create a column nothing declares. Nothing was executed.
100
+ */
101
+ status: 'renamed' | 'already_done' | 'table_missing' | 'skipped_not_declared' | 'error';
67
102
  error?: string;
68
103
  }
69
104
  /**
70
- * Rename `project_id` → `environment_id` on all metadata tables.
71
- *
72
- * @param driver An IDataDriver with access to the target database.
73
- * Must expose a raw query method: `driver.raw(sql, bindings?)`.
74
- * @returns Per-table migration results.
105
+ * Rename `project_id` → `environment_id` on all metadata tables that still
106
+ * declare `environment_id`.
107
+ *
108
+ * @param driver An IDataDriver with access to the target database. Raw SQL is
109
+ * issued through the surface `IDataDriver` declares —
110
+ * `execute(sql, bindings?)` — falling back to
111
+ * `raw(sql, bindings?)`; see `./driver-exec.ts`.
112
+ * @returns Per-table migration results — one entry per candidate table,
113
+ * including the ones skipped for lacking the declared target.
75
114
  */
76
115
  declare function migrateProjectIdToEnvironmentId(driver: IDataDriver): Promise<ProjectIdToEnvironmentIdResult[]>;
77
116
 
@@ -100,7 +139,9 @@ interface DropProjectionResult {
100
139
  /**
101
140
  * Drop the deprecated per-type metadata projection tables.
102
141
  *
103
- * @param driver An `IDataDriver` with `driver.raw(sql, bindings?)` access.
142
+ * @param driver An `IDataDriver`. Raw SQL is issued through the surface
143
+ * `IDataDriver` declares — `execute(sql, bindings?)` — falling
144
+ * back to `raw(sql, bindings?)`; see `./driver-exec.ts`.
104
145
  * @returns Per-table results.
105
146
  */
106
147
  declare function dropProjectionTables(driver: IDataDriver): Promise<DropProjectionResult[]>;
@@ -132,7 +173,9 @@ declare function dropProjectionTables(driver: IDataDriver): Promise<DropProjecti
132
173
  * await migrateSysNotificationToEvent({ driver, data });
133
174
  *
134
175
  * `driver` provides raw access to read legacy columns the re-modeled schema no
135
- * longer projects and to clear them; `data` (IDataEngine) performs the
176
+ * longer projects and to clear them through the surface `IDataDriver`
177
+ * declares, `execute(sql, bindings?)`, falling back to `raw(sql, bindings?)`
178
+ * (see `./driver-exec.ts`); `data` (IDataEngine) performs the
136
179
  * structured inbox/receipt writes and the event rewrite so ids, JSON fields and
137
180
  * tenant stamping are handled uniformly across drivers.
138
181
  */