@objectstack/metadata 7.2.1 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +77 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +74 -4
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.cjs +126 -2
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +48 -2
- package/dist/migrations/index.d.ts +48 -2
- package/dist/migrations/index.js +124 -1
- package/dist/migrations/index.js.map +1 -1
- package/dist/node.cjs +77 -8
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +74 -4
- package/dist/node.js.map +1 -1
- package/package.json +7 -7
|
@@ -23,7 +23,8 @@ __export(migrations_exports, {
|
|
|
23
23
|
addSysMetadataOverlayIndex: () => addSysMetadataOverlayIndex,
|
|
24
24
|
dropProjectionTables: () => dropProjectionTables,
|
|
25
25
|
migrateEnvIdToProjectId: () => migrateEnvIdToProjectId,
|
|
26
|
-
migrateProjectIdToEnvironmentId: () => migrateProjectIdToEnvironmentId
|
|
26
|
+
migrateProjectIdToEnvironmentId: () => migrateProjectIdToEnvironmentId,
|
|
27
|
+
migrateSysNotificationToEvent: () => migrateSysNotificationToEvent
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(migrations_exports);
|
|
29
30
|
|
|
@@ -201,11 +202,134 @@ async function addSysMetadataOverlayIndex(driver) {
|
|
|
201
202
|
return { index: INDEX_NAME, status: "error", error: msg };
|
|
202
203
|
}
|
|
203
204
|
}
|
|
205
|
+
|
|
206
|
+
// src/migrations/migrate-sys-notification-to-event.ts
|
|
207
|
+
var EVENT_OBJECT = "sys_notification";
|
|
208
|
+
var INBOX_OBJECT = "sys_inbox_message";
|
|
209
|
+
var RECEIPT_OBJECT = "sys_notification_receipt";
|
|
210
|
+
var LEGACY_COLUMNS = [
|
|
211
|
+
"recipient_id",
|
|
212
|
+
"type",
|
|
213
|
+
"title",
|
|
214
|
+
"body",
|
|
215
|
+
"url",
|
|
216
|
+
"actor_name",
|
|
217
|
+
"is_read",
|
|
218
|
+
"read_at"
|
|
219
|
+
];
|
|
220
|
+
async function migrateSysNotificationToEvent(opts) {
|
|
221
|
+
const driver = opts.driver;
|
|
222
|
+
const { data } = opts;
|
|
223
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
224
|
+
if (typeof driver?.raw !== "function") {
|
|
225
|
+
return {
|
|
226
|
+
status: "error",
|
|
227
|
+
migrated: 0,
|
|
228
|
+
error: "migrateSysNotificationToEvent: driver must expose a .raw(sql, bindings?) method."
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (!await columnExists(driver, EVENT_OBJECT, "recipient_id")) {
|
|
232
|
+
return { status: "not_applicable", migrated: 0 };
|
|
233
|
+
}
|
|
234
|
+
const presentLegacy = [];
|
|
235
|
+
for (const col of LEGACY_COLUMNS) {
|
|
236
|
+
if (await columnExists(driver, EVENT_OBJECT, col)) presentLegacy.push(col);
|
|
237
|
+
}
|
|
238
|
+
let migrated = 0;
|
|
239
|
+
try {
|
|
240
|
+
const rows = await selectLegacyRows(driver);
|
|
241
|
+
if (rows.length === 0) return { status: "already_done", migrated: 0 };
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
const id = String(row.id);
|
|
244
|
+
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
|
|
245
|
+
if (!recipientId) continue;
|
|
246
|
+
const orgId = row.organization_id != null ? String(row.organization_id) : null;
|
|
247
|
+
const createdAt = row.created_at != null ? String(row.created_at) : now();
|
|
248
|
+
const title = row.title != null ? String(row.title) : row.type != null ? String(row.type) : "Notification";
|
|
249
|
+
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === "1";
|
|
250
|
+
const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : "legacy";
|
|
251
|
+
await data.insert(INBOX_OBJECT, {
|
|
252
|
+
user_id: recipientId,
|
|
253
|
+
notification_id: id,
|
|
254
|
+
topic: eventTopic,
|
|
255
|
+
title,
|
|
256
|
+
body_md: row.body ?? null,
|
|
257
|
+
severity: "info",
|
|
258
|
+
action_url: row.url ?? null,
|
|
259
|
+
organization_id: orgId,
|
|
260
|
+
created_at: createdAt
|
|
261
|
+
});
|
|
262
|
+
await data.insert(RECEIPT_OBJECT, {
|
|
263
|
+
notification_id: id,
|
|
264
|
+
delivery_id: null,
|
|
265
|
+
user_id: recipientId,
|
|
266
|
+
channel: "inbox",
|
|
267
|
+
state: isRead ? "read" : "delivered",
|
|
268
|
+
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
|
|
269
|
+
organization_id: orgId,
|
|
270
|
+
created_at: createdAt
|
|
271
|
+
});
|
|
272
|
+
await data.update(
|
|
273
|
+
EVENT_OBJECT,
|
|
274
|
+
{
|
|
275
|
+
id,
|
|
276
|
+
topic: eventTopic,
|
|
277
|
+
severity: "info",
|
|
278
|
+
payload: {
|
|
279
|
+
title: row.title ?? null,
|
|
280
|
+
body: row.body ?? null,
|
|
281
|
+
url: row.url ?? null,
|
|
282
|
+
actorName: row.actor_name ?? null
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{ where: { id } }
|
|
286
|
+
);
|
|
287
|
+
if (presentLegacy.length > 0) {
|
|
288
|
+
const setClause = presentLegacy.map((c) => `"${c}" = NULL`).join(", ");
|
|
289
|
+
await driver.raw(`UPDATE "${EVENT_OBJECT}" SET ${setClause} WHERE id = ?`, [id]);
|
|
290
|
+
}
|
|
291
|
+
migrated += 1;
|
|
292
|
+
}
|
|
293
|
+
return { status: "migrated", migrated };
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return { status: "error", migrated, error: err?.message ?? String(err) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function selectLegacyRows(driver) {
|
|
299
|
+
const result = await driver.raw(
|
|
300
|
+
`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`
|
|
301
|
+
);
|
|
302
|
+
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {
|
|
303
|
+
return result[0];
|
|
304
|
+
}
|
|
305
|
+
return Array.isArray(result) ? result : [];
|
|
306
|
+
}
|
|
307
|
+
async function columnExists(driver, table, column) {
|
|
308
|
+
try {
|
|
309
|
+
const rows = await driver.raw(`PRAGMA table_info("${table}")`);
|
|
310
|
+
const list = Array.isArray(rows) ? Array.isArray(rows[0]) ? rows[0] : rows : [];
|
|
311
|
+
if (list.length > 0 && list.some((r) => r?.name != null)) {
|
|
312
|
+
return list.some((r) => r?.name === column);
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const result = await driver.raw(
|
|
318
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
|
319
|
+
[table, column]
|
|
320
|
+
);
|
|
321
|
+
const list = Array.isArray(result) ? Array.isArray(result[0]) ? result[0] : result : [];
|
|
322
|
+
return list.length > 0;
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
204
327
|
// Annotate the CommonJS export names for ESM import in node:
|
|
205
328
|
0 && (module.exports = {
|
|
206
329
|
addSysMetadataOverlayIndex,
|
|
207
330
|
dropProjectionTables,
|
|
208
331
|
migrateEnvIdToProjectId,
|
|
209
|
-
migrateProjectIdToEnvironmentId
|
|
332
|
+
migrateProjectIdToEnvironmentId,
|
|
333
|
+
migrateSysNotificationToEvent
|
|
210
334
|
});
|
|
211
335
|
//# sourceMappingURL=index.cjs.map
|
|
@@ -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/add-sys-metadata-overlay-index.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';\nexport {\n addSysMetadataOverlayIndex,\n type AddSysMetadataOverlayIndexResult,\n} from './add-sys-metadata-overlay-index.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: ensure overlay-uniqueness index exists on `sys_metadata`.\n *\n * ADR-0005 Phase 1 — Overlay rows must be uniquely keyed by\n * `(type, name, organization_id, environment_id, scope)` for active rows only.\n * The previous `(type, name, environment_id)` unique constraint pre-dated\n * multi-tenant overlays and would incorrectly reject per-org customizations.\n *\n * Behaviour:\n * - SQLite / Postgres: creates a partial UNIQUE INDEX with `WHERE state = 'active'`.\n * - MySQL (no partial-index support): falls back to a non-unique composite index\n * plus an application-level guard (handled in `protocol.ts saveMetaItem`).\n * - Idempotent — uses `CREATE INDEX IF NOT EXISTS`. Safe to run on every boot.\n * - Best-effort: failures are recorded but never throw, so tenant boot is\n * not blocked on a database that doesn't support partial indexes.\n *\n * Usage:\n * import { addSysMetadataOverlayIndex } from '@objectstack/metadata/migrations';\n * await addSysMetadataOverlayIndex(driver);\n *\n * The `DatabaseLoader.ensureSchema()` invokes this automatically after the\n * `sys_metadata` table is created/synced, so most callers do not need to\n * invoke it directly.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst INDEX_NAME = 'idx_sys_metadata_overlay_active';\nconst TABLE = 'sys_metadata';\nconst COLUMNS = '(type, name, organization_id, environment_id, scope)';\nconst WHERE = \"state = 'active'\";\n\nexport interface AddSysMetadataOverlayIndexResult {\n index: string;\n status: 'created' | 'already_exists' | 'fallback_non_unique' | 'unsupported' | 'error';\n error?: string;\n}\n\n/**\n * Ensure the overlay-uniqueness index exists on `sys_metadata`.\n *\n * @param driver An `IDataDriver` exposing a `raw(sql, bindings?)` method.\n */\nexport async function addSysMetadataOverlayIndex(\n driver: IDataDriver,\n): Promise<AddSysMetadataOverlayIndexResult> {\n const driverAny = driver as any;\n const exec = async (sql: string): Promise<void> => {\n if (typeof driverAny.raw === 'function') {\n await driverAny.raw(sql);\n } else if (typeof driverAny.execute === 'function') {\n await driverAny.execute(sql);\n } else {\n throw new Error('driver has neither raw nor execute');\n }\n };\n\n const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;\n const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;\n\n try {\n await exec(partialSql);\n return { index: INDEX_NAME, status: 'created' };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n\n // Partial-index unsupported (typically MySQL): fall back to a plain composite index.\n if (/partial|where clause|syntax/i.test(msg)) {\n try {\n await exec(fallbackSql);\n return { index: INDEX_NAME, status: 'fallback_non_unique' };\n } catch (fallbackErr) {\n return {\n index: INDEX_NAME,\n status: 'error',\n error:\n fallbackErr instanceof Error\n ? fallbackErr.message\n : String(fallbackErr),\n };\n }\n }\n\n if (/already exists/i.test(msg)) {\n return { index: INDEX_NAME, status: 'already_exists' };\n }\n\n return { index: INDEX_NAME, status: 'error', error: msg };\n }\n}\n"],"mappings":"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","names":["list","AFFECTED_TABLES","_columnExists","list"]}
|
|
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/add-sys-metadata-overlay-index.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';\nexport {\n addSysMetadataOverlayIndex,\n type AddSysMetadataOverlayIndexResult,\n} from './add-sys-metadata-overlay-index.js';\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: ensure overlay-uniqueness index exists on `sys_metadata`.\n *\n * ADR-0005 Phase 1 — Overlay rows must be uniquely keyed by\n * `(type, name, organization_id, environment_id, scope)` for active rows only.\n * The previous `(type, name, environment_id)` unique constraint pre-dated\n * multi-tenant overlays and would incorrectly reject per-org customizations.\n *\n * Behaviour:\n * - SQLite / Postgres: creates a partial UNIQUE INDEX with `WHERE state = 'active'`.\n * - MySQL (no partial-index support): falls back to a non-unique composite index\n * plus an application-level guard (handled in `protocol.ts saveMetaItem`).\n * - Idempotent — uses `CREATE INDEX IF NOT EXISTS`. Safe to run on every boot.\n * - Best-effort: failures are recorded but never throw, so tenant boot is\n * not blocked on a database that doesn't support partial indexes.\n *\n * Usage:\n * import { addSysMetadataOverlayIndex } from '@objectstack/metadata/migrations';\n * await addSysMetadataOverlayIndex(driver);\n *\n * The `DatabaseLoader.ensureSchema()` invokes this automatically after the\n * `sys_metadata` table is created/synced, so most callers do not need to\n * invoke it directly.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst INDEX_NAME = 'idx_sys_metadata_overlay_active';\nconst TABLE = 'sys_metadata';\nconst COLUMNS = '(type, name, organization_id, environment_id, scope)';\nconst WHERE = \"state = 'active'\";\n\nexport interface AddSysMetadataOverlayIndexResult {\n index: string;\n status: 'created' | 'already_exists' | 'fallback_non_unique' | 'unsupported' | 'error';\n error?: string;\n}\n\n/**\n * Ensure the overlay-uniqueness index exists on `sys_metadata`.\n *\n * @param driver An `IDataDriver` exposing a `raw(sql, bindings?)` method.\n */\nexport async function addSysMetadataOverlayIndex(\n driver: IDataDriver,\n): Promise<AddSysMetadataOverlayIndexResult> {\n const driverAny = driver as any;\n const exec = async (sql: string): Promise<void> => {\n if (typeof driverAny.raw === 'function') {\n await driverAny.raw(sql);\n } else if (typeof driverAny.execute === 'function') {\n await driverAny.execute(sql);\n } else {\n throw new Error('driver has neither raw nor execute');\n }\n };\n\n const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;\n const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;\n\n try {\n await exec(partialSql);\n return { index: INDEX_NAME, status: 'created' };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n\n // Partial-index unsupported (typically MySQL): fall back to a plain composite index.\n if (/partial|where clause|syntax/i.test(msg)) {\n try {\n await exec(fallbackSql);\n return { index: INDEX_NAME, status: 'fallback_non_unique' };\n } catch (fallbackErr) {\n return {\n index: INDEX_NAME,\n status: 'error',\n error:\n fallbackErr instanceof Error\n ? fallbackErr.message\n : String(fallbackErr),\n };\n }\n }\n\n if (/already exists/i.test(msg)) {\n return { index: INDEX_NAME, status: 'already_exists' };\n }\n\n return { index: INDEX_NAME, status: 'error', error: msg };\n }\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,4 +1,4 @@
|
|
|
1
|
-
import { IDataDriver } from '@objectstack/spec/contracts';
|
|
1
|
+
import { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Migration: env_id → project_id
|
|
@@ -142,4 +142,50 @@ interface AddSysMetadataOverlayIndexResult {
|
|
|
142
142
|
*/
|
|
143
143
|
declare function addSysMetadataOverlayIndex(driver: IDataDriver): Promise<AddSysMetadataOverlayIndexResult>;
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Migration: sys_notification (per-user inbox) → notification event (ADR-0030)
|
|
147
|
+
*
|
|
148
|
+
* ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2
|
|
149
|
+
* *event* (one row per `emit`). This migration preserves users' existing bell
|
|
150
|
+
* notifications across the cut-over by splitting each legacy row into the new
|
|
151
|
+
* layered model:
|
|
152
|
+
*
|
|
153
|
+
* legacy sys_notification row (recipient_id, type, title, body, url,
|
|
154
|
+
* actor_name, is_read, read_at, …)
|
|
155
|
+
* │
|
|
156
|
+
* ├─► sys_inbox_message (L5 in-app materialization, keyed by user)
|
|
157
|
+
* ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered')
|
|
158
|
+
* └─► the sys_notification row itself is rewritten to the event shape
|
|
159
|
+
* (topic ← type, payload ← {title,body,url,actor_name}) and its legacy
|
|
160
|
+
* inbox columns are cleared.
|
|
161
|
+
*
|
|
162
|
+
* Idempotent: it acts only on rows that still carry the legacy shape
|
|
163
|
+
* (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy
|
|
164
|
+
* columns were never present (a fresh install created directly in the new
|
|
165
|
+
* shape) — it reports `not_applicable`.
|
|
166
|
+
*
|
|
167
|
+
* Usage:
|
|
168
|
+
* import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations';
|
|
169
|
+
* await migrateSysNotificationToEvent({ driver, data });
|
|
170
|
+
*
|
|
171
|
+
* `driver` provides raw access to read legacy columns the re-modeled schema no
|
|
172
|
+
* longer projects and to clear them; `data` (IDataEngine) performs the
|
|
173
|
+
* structured inbox/receipt writes and the event rewrite so ids, JSON fields and
|
|
174
|
+
* tenant stamping are handled uniformly across drivers.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
interface SysNotificationMigrationResult {
|
|
178
|
+
status: 'migrated' | 'already_done' | 'not_applicable' | 'error';
|
|
179
|
+
/** Number of legacy rows split into inbox + receipt + event. */
|
|
180
|
+
migrated: number;
|
|
181
|
+
error?: string;
|
|
182
|
+
}
|
|
183
|
+
interface SysNotificationMigrationOptions {
|
|
184
|
+
driver: IDataDriver;
|
|
185
|
+
data: IDataEngine;
|
|
186
|
+
/** Defaults to `() => new Date().toISOString()`. */
|
|
187
|
+
now?(): string;
|
|
188
|
+
}
|
|
189
|
+
declare function migrateSysNotificationToEvent(opts: SysNotificationMigrationOptions): Promise<SysNotificationMigrationResult>;
|
|
190
|
+
|
|
191
|
+
export { type AddSysMetadataOverlayIndexResult, type DropProjectionResult, type MigrationResult, type ProjectIdToEnvironmentIdResult, type SysNotificationMigrationOptions, type SysNotificationMigrationResult, addSysMetadataOverlayIndex, dropProjectionTables, migrateEnvIdToProjectId, migrateProjectIdToEnvironmentId, migrateSysNotificationToEvent };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IDataDriver } from '@objectstack/spec/contracts';
|
|
1
|
+
import { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Migration: env_id → project_id
|
|
@@ -142,4 +142,50 @@ interface AddSysMetadataOverlayIndexResult {
|
|
|
142
142
|
*/
|
|
143
143
|
declare function addSysMetadataOverlayIndex(driver: IDataDriver): Promise<AddSysMetadataOverlayIndexResult>;
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Migration: sys_notification (per-user inbox) → notification event (ADR-0030)
|
|
147
|
+
*
|
|
148
|
+
* ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2
|
|
149
|
+
* *event* (one row per `emit`). This migration preserves users' existing bell
|
|
150
|
+
* notifications across the cut-over by splitting each legacy row into the new
|
|
151
|
+
* layered model:
|
|
152
|
+
*
|
|
153
|
+
* legacy sys_notification row (recipient_id, type, title, body, url,
|
|
154
|
+
* actor_name, is_read, read_at, …)
|
|
155
|
+
* │
|
|
156
|
+
* ├─► sys_inbox_message (L5 in-app materialization, keyed by user)
|
|
157
|
+
* ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered')
|
|
158
|
+
* └─► the sys_notification row itself is rewritten to the event shape
|
|
159
|
+
* (topic ← type, payload ← {title,body,url,actor_name}) and its legacy
|
|
160
|
+
* inbox columns are cleared.
|
|
161
|
+
*
|
|
162
|
+
* Idempotent: it acts only on rows that still carry the legacy shape
|
|
163
|
+
* (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy
|
|
164
|
+
* columns were never present (a fresh install created directly in the new
|
|
165
|
+
* shape) — it reports `not_applicable`.
|
|
166
|
+
*
|
|
167
|
+
* Usage:
|
|
168
|
+
* import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations';
|
|
169
|
+
* await migrateSysNotificationToEvent({ driver, data });
|
|
170
|
+
*
|
|
171
|
+
* `driver` provides raw access to read legacy columns the re-modeled schema no
|
|
172
|
+
* longer projects and to clear them; `data` (IDataEngine) performs the
|
|
173
|
+
* structured inbox/receipt writes and the event rewrite so ids, JSON fields and
|
|
174
|
+
* tenant stamping are handled uniformly across drivers.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
interface SysNotificationMigrationResult {
|
|
178
|
+
status: 'migrated' | 'already_done' | 'not_applicable' | 'error';
|
|
179
|
+
/** Number of legacy rows split into inbox + receipt + event. */
|
|
180
|
+
migrated: number;
|
|
181
|
+
error?: string;
|
|
182
|
+
}
|
|
183
|
+
interface SysNotificationMigrationOptions {
|
|
184
|
+
driver: IDataDriver;
|
|
185
|
+
data: IDataEngine;
|
|
186
|
+
/** Defaults to `() => new Date().toISOString()`. */
|
|
187
|
+
now?(): string;
|
|
188
|
+
}
|
|
189
|
+
declare function migrateSysNotificationToEvent(opts: SysNotificationMigrationOptions): Promise<SysNotificationMigrationResult>;
|
|
190
|
+
|
|
191
|
+
export { type AddSysMetadataOverlayIndexResult, type DropProjectionResult, type MigrationResult, type ProjectIdToEnvironmentIdResult, type SysNotificationMigrationOptions, type SysNotificationMigrationResult, addSysMetadataOverlayIndex, dropProjectionTables, migrateEnvIdToProjectId, migrateProjectIdToEnvironmentId, migrateSysNotificationToEvent };
|
package/dist/migrations/index.js
CHANGED
|
@@ -172,10 +172,133 @@ async function addSysMetadataOverlayIndex(driver) {
|
|
|
172
172
|
return { index: INDEX_NAME, status: "error", error: msg };
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
|
|
176
|
+
// src/migrations/migrate-sys-notification-to-event.ts
|
|
177
|
+
var EVENT_OBJECT = "sys_notification";
|
|
178
|
+
var INBOX_OBJECT = "sys_inbox_message";
|
|
179
|
+
var RECEIPT_OBJECT = "sys_notification_receipt";
|
|
180
|
+
var LEGACY_COLUMNS = [
|
|
181
|
+
"recipient_id",
|
|
182
|
+
"type",
|
|
183
|
+
"title",
|
|
184
|
+
"body",
|
|
185
|
+
"url",
|
|
186
|
+
"actor_name",
|
|
187
|
+
"is_read",
|
|
188
|
+
"read_at"
|
|
189
|
+
];
|
|
190
|
+
async function migrateSysNotificationToEvent(opts) {
|
|
191
|
+
const driver = opts.driver;
|
|
192
|
+
const { data } = opts;
|
|
193
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
194
|
+
if (typeof driver?.raw !== "function") {
|
|
195
|
+
return {
|
|
196
|
+
status: "error",
|
|
197
|
+
migrated: 0,
|
|
198
|
+
error: "migrateSysNotificationToEvent: driver must expose a .raw(sql, bindings?) method."
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (!await columnExists(driver, EVENT_OBJECT, "recipient_id")) {
|
|
202
|
+
return { status: "not_applicable", migrated: 0 };
|
|
203
|
+
}
|
|
204
|
+
const presentLegacy = [];
|
|
205
|
+
for (const col of LEGACY_COLUMNS) {
|
|
206
|
+
if (await columnExists(driver, EVENT_OBJECT, col)) presentLegacy.push(col);
|
|
207
|
+
}
|
|
208
|
+
let migrated = 0;
|
|
209
|
+
try {
|
|
210
|
+
const rows = await selectLegacyRows(driver);
|
|
211
|
+
if (rows.length === 0) return { status: "already_done", migrated: 0 };
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
const id = String(row.id);
|
|
214
|
+
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
|
|
215
|
+
if (!recipientId) continue;
|
|
216
|
+
const orgId = row.organization_id != null ? String(row.organization_id) : null;
|
|
217
|
+
const createdAt = row.created_at != null ? String(row.created_at) : now();
|
|
218
|
+
const title = row.title != null ? String(row.title) : row.type != null ? String(row.type) : "Notification";
|
|
219
|
+
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === "1";
|
|
220
|
+
const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : "legacy";
|
|
221
|
+
await data.insert(INBOX_OBJECT, {
|
|
222
|
+
user_id: recipientId,
|
|
223
|
+
notification_id: id,
|
|
224
|
+
topic: eventTopic,
|
|
225
|
+
title,
|
|
226
|
+
body_md: row.body ?? null,
|
|
227
|
+
severity: "info",
|
|
228
|
+
action_url: row.url ?? null,
|
|
229
|
+
organization_id: orgId,
|
|
230
|
+
created_at: createdAt
|
|
231
|
+
});
|
|
232
|
+
await data.insert(RECEIPT_OBJECT, {
|
|
233
|
+
notification_id: id,
|
|
234
|
+
delivery_id: null,
|
|
235
|
+
user_id: recipientId,
|
|
236
|
+
channel: "inbox",
|
|
237
|
+
state: isRead ? "read" : "delivered",
|
|
238
|
+
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
|
|
239
|
+
organization_id: orgId,
|
|
240
|
+
created_at: createdAt
|
|
241
|
+
});
|
|
242
|
+
await data.update(
|
|
243
|
+
EVENT_OBJECT,
|
|
244
|
+
{
|
|
245
|
+
id,
|
|
246
|
+
topic: eventTopic,
|
|
247
|
+
severity: "info",
|
|
248
|
+
payload: {
|
|
249
|
+
title: row.title ?? null,
|
|
250
|
+
body: row.body ?? null,
|
|
251
|
+
url: row.url ?? null,
|
|
252
|
+
actorName: row.actor_name ?? null
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{ where: { id } }
|
|
256
|
+
);
|
|
257
|
+
if (presentLegacy.length > 0) {
|
|
258
|
+
const setClause = presentLegacy.map((c) => `"${c}" = NULL`).join(", ");
|
|
259
|
+
await driver.raw(`UPDATE "${EVENT_OBJECT}" SET ${setClause} WHERE id = ?`, [id]);
|
|
260
|
+
}
|
|
261
|
+
migrated += 1;
|
|
262
|
+
}
|
|
263
|
+
return { status: "migrated", migrated };
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return { status: "error", migrated, error: err?.message ?? String(err) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function selectLegacyRows(driver) {
|
|
269
|
+
const result = await driver.raw(
|
|
270
|
+
`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`
|
|
271
|
+
);
|
|
272
|
+
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {
|
|
273
|
+
return result[0];
|
|
274
|
+
}
|
|
275
|
+
return Array.isArray(result) ? result : [];
|
|
276
|
+
}
|
|
277
|
+
async function columnExists(driver, table, column) {
|
|
278
|
+
try {
|
|
279
|
+
const rows = await driver.raw(`PRAGMA table_info("${table}")`);
|
|
280
|
+
const list = Array.isArray(rows) ? Array.isArray(rows[0]) ? rows[0] : rows : [];
|
|
281
|
+
if (list.length > 0 && list.some((r) => r?.name != null)) {
|
|
282
|
+
return list.some((r) => r?.name === column);
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const result = await driver.raw(
|
|
288
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
|
289
|
+
[table, column]
|
|
290
|
+
);
|
|
291
|
+
const list = Array.isArray(result) ? Array.isArray(result[0]) ? result[0] : result : [];
|
|
292
|
+
return list.length > 0;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
175
297
|
export {
|
|
176
298
|
addSysMetadataOverlayIndex,
|
|
177
299
|
dropProjectionTables,
|
|
178
300
|
migrateEnvIdToProjectId,
|
|
179
|
-
migrateProjectIdToEnvironmentId
|
|
301
|
+
migrateProjectIdToEnvironmentId,
|
|
302
|
+
migrateSysNotificationToEvent
|
|
180
303
|
};
|
|
181
304
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../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/add-sys-metadata-overlay-index.ts"],"sourcesContent":["// 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: ensure overlay-uniqueness index exists on `sys_metadata`.\n *\n * ADR-0005 Phase 1 — Overlay rows must be uniquely keyed by\n * `(type, name, organization_id, environment_id, scope)` for active rows only.\n * The previous `(type, name, environment_id)` unique constraint pre-dated\n * multi-tenant overlays and would incorrectly reject per-org customizations.\n *\n * Behaviour:\n * - SQLite / Postgres: creates a partial UNIQUE INDEX with `WHERE state = 'active'`.\n * - MySQL (no partial-index support): falls back to a non-unique composite index\n * plus an application-level guard (handled in `protocol.ts saveMetaItem`).\n * - Idempotent — uses `CREATE INDEX IF NOT EXISTS`. Safe to run on every boot.\n * - Best-effort: failures are recorded but never throw, so tenant boot is\n * not blocked on a database that doesn't support partial indexes.\n *\n * Usage:\n * import { addSysMetadataOverlayIndex } from '@objectstack/metadata/migrations';\n * await addSysMetadataOverlayIndex(driver);\n *\n * The `DatabaseLoader.ensureSchema()` invokes this automatically after the\n * `sys_metadata` table is created/synced, so most callers do not need to\n * invoke it directly.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst INDEX_NAME = 'idx_sys_metadata_overlay_active';\nconst TABLE = 'sys_metadata';\nconst COLUMNS = '(type, name, organization_id, environment_id, scope)';\nconst WHERE = \"state = 'active'\";\n\nexport interface AddSysMetadataOverlayIndexResult {\n index: string;\n status: 'created' | 'already_exists' | 'fallback_non_unique' | 'unsupported' | 'error';\n error?: string;\n}\n\n/**\n * Ensure the overlay-uniqueness index exists on `sys_metadata`.\n *\n * @param driver An `IDataDriver` exposing a `raw(sql, bindings?)` method.\n */\nexport async function addSysMetadataOverlayIndex(\n driver: IDataDriver,\n): Promise<AddSysMetadataOverlayIndexResult> {\n const driverAny = driver as any;\n const exec = async (sql: string): Promise<void> => {\n if (typeof driverAny.raw === 'function') {\n await driverAny.raw(sql);\n } else if (typeof driverAny.execute === 'function') {\n await driverAny.execute(sql);\n } else {\n throw new Error('driver has neither raw nor execute');\n }\n };\n\n const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;\n const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;\n\n try {\n await exec(partialSql);\n return { index: INDEX_NAME, status: 'created' };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n\n // Partial-index unsupported (typically MySQL): fall back to a plain composite index.\n if (/partial|where clause|syntax/i.test(msg)) {\n try {\n await exec(fallbackSql);\n return { index: INDEX_NAME, status: 'fallback_non_unique' };\n } catch (fallbackErr) {\n return {\n index: INDEX_NAME,\n status: 'error',\n error:\n fallbackErr instanceof Error\n ? fallbackErr.message\n : String(fallbackErr),\n };\n }\n }\n\n if (/already exists/i.test(msg)) {\n return { index: INDEX_NAME, status: 'already_exists' };\n }\n\n return { index: INDEX_NAME, status: 'error', error: msg };\n }\n}\n"],"mappings":"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","names":["list","AFFECTED_TABLES","_columnExists","list"]}
|
|
1
|
+
{"version":3,"sources":["../../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/add-sys-metadata-overlay-index.ts","../../src/migrations/migrate-sys-notification-to-event.ts"],"sourcesContent":["// 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: ensure overlay-uniqueness index exists on `sys_metadata`.\n *\n * ADR-0005 Phase 1 — Overlay rows must be uniquely keyed by\n * `(type, name, organization_id, environment_id, scope)` for active rows only.\n * The previous `(type, name, environment_id)` unique constraint pre-dated\n * multi-tenant overlays and would incorrectly reject per-org customizations.\n *\n * Behaviour:\n * - SQLite / Postgres: creates a partial UNIQUE INDEX with `WHERE state = 'active'`.\n * - MySQL (no partial-index support): falls back to a non-unique composite index\n * plus an application-level guard (handled in `protocol.ts saveMetaItem`).\n * - Idempotent — uses `CREATE INDEX IF NOT EXISTS`. Safe to run on every boot.\n * - Best-effort: failures are recorded but never throw, so tenant boot is\n * not blocked on a database that doesn't support partial indexes.\n *\n * Usage:\n * import { addSysMetadataOverlayIndex } from '@objectstack/metadata/migrations';\n * await addSysMetadataOverlayIndex(driver);\n *\n * The `DatabaseLoader.ensureSchema()` invokes this automatically after the\n * `sys_metadata` table is created/synced, so most callers do not need to\n * invoke it directly.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nconst INDEX_NAME = 'idx_sys_metadata_overlay_active';\nconst TABLE = 'sys_metadata';\nconst COLUMNS = '(type, name, organization_id, environment_id, scope)';\nconst WHERE = \"state = 'active'\";\n\nexport interface AddSysMetadataOverlayIndexResult {\n index: string;\n status: 'created' | 'already_exists' | 'fallback_non_unique' | 'unsupported' | 'error';\n error?: string;\n}\n\n/**\n * Ensure the overlay-uniqueness index exists on `sys_metadata`.\n *\n * @param driver An `IDataDriver` exposing a `raw(sql, bindings?)` method.\n */\nexport async function addSysMetadataOverlayIndex(\n driver: IDataDriver,\n): Promise<AddSysMetadataOverlayIndexResult> {\n const driverAny = driver as any;\n const exec = async (sql: string): Promise<void> => {\n if (typeof driverAny.raw === 'function') {\n await driverAny.raw(sql);\n } else if (typeof driverAny.execute === 'function') {\n await driverAny.execute(sql);\n } else {\n throw new Error('driver has neither raw nor execute');\n }\n };\n\n const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;\n const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;\n\n try {\n await exec(partialSql);\n return { index: INDEX_NAME, status: 'created' };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n\n // Partial-index unsupported (typically MySQL): fall back to a plain composite index.\n if (/partial|where clause|syntax/i.test(msg)) {\n try {\n await exec(fallbackSql);\n return { index: INDEX_NAME, status: 'fallback_non_unique' };\n } catch (fallbackErr) {\n return {\n index: INDEX_NAME,\n status: 'error',\n error:\n fallbackErr instanceof Error\n ? fallbackErr.message\n : String(fallbackErr),\n };\n }\n }\n\n if (/already exists/i.test(msg)) {\n return { index: INDEX_NAME, status: 'already_exists' };\n }\n\n return { index: INDEX_NAME, status: 'error', error: msg };\n }\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"]}
|