@lenne.tech/nest-server 12.1.0 → 12.2.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/.claude/rules/architecture.md +1 -0
- package/.claude/rules/better-auth.md +18 -6
- package/.claude/rules/configurable-features.md +5 -4
- package/.claude/rules/package-management.md +16 -1
- package/.claude/rules/persistence.md +26 -0
- package/.claude/rules/testing.md +102 -28
- package/CLAUDE.md +3 -2
- package/FRAMEWORK-API.md +17 -2
- package/README.md +24 -3
- package/dist/core/common/helpers/file.helper.js +3 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
- package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
- package/dist/core/common/persistence/core-persistence.module.js +12 -2
- package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
- package/dist/core/common/persistence/data-source.runtime.js +14 -19
- package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
- package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
- package/dist/core/common/persistence/id-format.d.ts +2 -0
- package/dist/core/common/persistence/id-format.js +14 -0
- package/dist/core/common/persistence/id-format.js.map +1 -1
- package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
- package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
- package/dist/core/common/persistence/model-metadata.registry.js +20 -2
- package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
- package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
- package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
- package/dist/core/common/persistence/mongoose-data-store.js +107 -31
- package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
- package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
- package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
- package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
- package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
- package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
- package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
- package/dist/core/common/persistence/policies/sync.policy.js +77 -0
- package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
- package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
- package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
- package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
- package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
- package/dist/core/common/persistence/sql/sql-errors.js +3 -1
- package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
- package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
- package/dist/core/common/persistence/sync-access.registry.js +8 -0
- package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
- package/dist/core/common/persistence/sync-model.d.ts +29 -0
- package/dist/core/common/persistence/sync-model.js +138 -0
- package/dist/core/common/persistence/sync-model.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
- package/dist/core/common/services/core-cron-jobs.service.js +269 -13
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.js +5 -5
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
- package/dist/core/common/services/core-redis.service.d.ts +6 -0
- package/dist/core/common/services/core-redis.service.js +57 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -1
- package/dist/core/common/services/crud.service.d.ts +13 -1
- package/dist/core/common/services/crud.service.js +174 -16
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
- package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +54 -0
- package/dist/core/modules/error-code/error-codes.js +48 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.js +3 -4
- package/dist/core/modules/hub/hub-buffer.js.map +1 -1
- package/dist/core/modules/migrate/index.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
- package/dist/core/modules/migrate/migration-runner.js +28 -2
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
- package/dist/core/modules/migrate/mongo-state-store.js +0 -5
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
- package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
- package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
- package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
- package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
- package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
- package/dist/core/modules/sync/core-sync-cursor.js +77 -0
- package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
- package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
- package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
- package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
- package/dist/core/modules/sync/core-sync-state.store.js +64 -0
- package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
- package/dist/core/modules/sync/core-sync.constants.js +23 -0
- package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
- package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
- package/dist/core/modules/sync/core-sync.controller.js +79 -0
- package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
- package/dist/core/modules/sync/core-sync.decorators.js +28 -0
- package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
- package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
- package/dist/core/modules/sync/core-sync.module.js +50 -0
- package/dist/core/modules/sync/core-sync.module.js.map +1 -0
- package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
- package/dist/core/modules/sync/core-sync.resolver.js +46 -0
- package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
- package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
- package/dist/core/modules/sync/core-sync.service.js +1208 -0
- package/dist/core/modules/sync/core-sync.service.js.map +1 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core.module.js +14 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.model.js +73 -0
- package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
- package/dist/server/modules/sync-note/sync-note.module.js +21 -0
- package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.service.js +35 -0
- package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
- package/docs/REQUEST-LIFECYCLE.md +3 -2
- package/docs/choosing-a-database.md +7 -4
- package/docs/offline-sync-protocol.md +410 -0
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.0.x-to-12.1.x.md +9 -4
- package/migration-guides/12.1.0-to-12.1.1.md +125 -0
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/package.json +10 -8
- package/src/core/common/helpers/file.helper.ts +7 -5
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
- package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
- package/src/core/common/interfaces/server-options.interface.ts +149 -0
- package/src/core/common/interfaces/service-options.interface.ts +15 -0
- package/src/core/common/persistence/core-persistence.module.ts +21 -2
- package/src/core/common/persistence/data-source.runtime.ts +22 -23
- package/src/core/common/persistence/data-store.interface.ts +65 -12
- package/src/core/common/persistence/id-format.ts +26 -0
- package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
- package/src/core/common/persistence/model-metadata.registry.ts +31 -2
- package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
- package/src/core/common/persistence/mongoose-data-store.ts +171 -35
- package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
- package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
- package/src/core/common/persistence/policies/sync.policy.ts +121 -0
- package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
- package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
- package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
- package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
- package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
- package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
- package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
- package/src/core/common/persistence/sql/sql-errors.ts +5 -1
- package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
- package/src/core/common/persistence/sync-access.registry.ts +50 -0
- package/src/core/common/persistence/sync-model.ts +270 -0
- package/src/core/common/services/core-cron-jobs.service.ts +441 -27
- package/src/core/common/services/core-redis-pubsub.ts +8 -6
- package/src/core/common/services/core-redis.service.ts +99 -0
- package/src/core/common/services/crud.service.ts +257 -19
- package/src/core/modules/better-auth/README.md +25 -1
- package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
- package/src/core/modules/better-auth/better-auth.config.ts +4 -3
- package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
- package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
- package/src/core/modules/error-code/error-codes.ts +59 -0
- package/src/core/modules/hub/README.md +5 -2
- package/src/core/modules/hub/hub-buffer.ts +13 -7
- package/src/core/modules/migrate/README.md +20 -11
- package/src/core/modules/migrate/index.ts +1 -0
- package/src/core/modules/migrate/migration-runner.ts +68 -3
- package/src/core/modules/migrate/mongo-state-store.ts +7 -30
- package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
- package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
- package/src/core/modules/sync/README.md +188 -0
- package/src/core/modules/sync/SECURITY.md +65 -0
- package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
- package/src/core/modules/sync/core-sync-cursor.ts +117 -0
- package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
- package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
- package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
- package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
- package/src/core/modules/sync/core-sync-state.store.ts +89 -0
- package/src/core/modules/sync/core-sync.constants.ts +51 -0
- package/src/core/modules/sync/core-sync.controller.ts +68 -0
- package/src/core/modules/sync/core-sync.decorators.ts +84 -0
- package/src/core/modules/sync/core-sync.module.ts +75 -0
- package/src/core/modules/sync/core-sync.resolver.ts +35 -0
- package/src/core/modules/sync/core-sync.service.ts +1728 -0
- package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
- package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
- package/src/core/modules/tenant/README.md +1 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
- package/src/core.module.ts +25 -0
- package/src/index.ts +21 -0
- package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
- package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
- package/src/server/modules/sync-note/sync-note.model.ts +77 -0
- package/src/server/modules/sync-note/sync-note.module.ts +16 -0
- package/src/server/modules/sync-note/sync-note.service.ts +25 -0
|
@@ -16,7 +16,12 @@ import {
|
|
|
16
16
|
desiredMysqlTable,
|
|
17
17
|
type MysqlDesiredColumn,
|
|
18
18
|
type MysqlDesiredTable,
|
|
19
|
+
LIVE_KEY_PREFIX,
|
|
20
|
+
MYSQL_SYNC_COUNTERS,
|
|
21
|
+
MYSQL_SYNC_TRIGGER_VERSION,
|
|
19
22
|
mysqlColumnDefinition,
|
|
23
|
+
mysqlSyncCountersStatement,
|
|
24
|
+
mysqlSyncTableStatements,
|
|
20
25
|
NULL_SENTINEL_PREFIX,
|
|
21
26
|
variantLabel,
|
|
22
27
|
} from './mysql-schema.compiler.js';
|
|
@@ -72,12 +77,18 @@ interface CatalogTable {
|
|
|
72
77
|
columns: Map<string, { collation: null | string; generated: boolean; type: string }>;
|
|
73
78
|
/** framework-named index → unique */
|
|
74
79
|
indexes: Map<string, boolean>;
|
|
80
|
+
/** Change-order triggers (`lt_sync_*`) on the table, as far as the connection may see them (the owner does) */
|
|
81
|
+
syncTriggers: Set<string>;
|
|
82
|
+
/** The trigger version the counter row records — readable by the application account too */
|
|
83
|
+
syncTriggerVersion: null | string;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
interface Catalog {
|
|
78
87
|
database: string;
|
|
79
88
|
/** Whether the application account holds every DML right on the database */
|
|
80
89
|
granted: boolean;
|
|
90
|
+
/** Whether the counter table of synchronized models exists */
|
|
91
|
+
syncCounters: boolean;
|
|
81
92
|
tables: Map<string, CatalogTable>;
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -109,6 +120,71 @@ export async function withMysqlSchemaLock<T>(owner: SqlExecutor, fn: (run: Run)
|
|
|
109
120
|
});
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
/** The collation of every table the framework creates — byte-wise, as identifiers and tokens compare on PostgreSQL */
|
|
124
|
+
const FRAMEWORK_COLLATION = 'utf8mb4_bin';
|
|
125
|
+
/**
|
|
126
|
+
* How long a collation switch waits for the metadata locks it needs, in seconds. `ALTER DATABASE` locks every table of
|
|
127
|
+
* the database exclusively, one after the other: behind an open transaction it waits, and every statement on the
|
|
128
|
+
* tables it already holds waits with it. The server's own default is a year
|
|
129
|
+
*/
|
|
130
|
+
const COLLATION_SWITCH_LOCK_WAIT_SECONDS = 5;
|
|
131
|
+
|
|
132
|
+
/** Whether any of `tables` does not exist yet — only then does a library's migration create a table */
|
|
133
|
+
export async function anyTableMissing(run: Run, tables: string[]): Promise<boolean> {
|
|
134
|
+
if (!tables.length) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const { rows } = await run({
|
|
138
|
+
params: tables,
|
|
139
|
+
text: `SELECT COUNT(*) AS n FROM information_schema.TABLES
|
|
140
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${tables.map((_, index) => `$${index + 1}`).join(', ')})`,
|
|
141
|
+
});
|
|
142
|
+
return Number(rows[0]?.n) < new Set(tables).size;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `fn` with the database's default collation switched to the framework's and back afterwards — for a library that
|
|
147
|
+
* creates its tables without naming a collation (Better-Auth). Its tables then compare byte-wise like the framework's,
|
|
148
|
+
* and its foreign keys to them can be created at all: MariaDB refuses a key between `utf8mb4_bin` (`users.id`) and the
|
|
149
|
+
* server's default (`Foreign key constraint is incorrectly formed`), and on MySQL tokens and identifiers would compare
|
|
150
|
+
* case-insensitively. Restored afterwards, so the project's own DDL keeps the default it was given. Runs as the owner
|
|
151
|
+
* under the schema lock (`run` is the lock's connection); a process that dies in between leaves `utf8mb4_bin` behind,
|
|
152
|
+
* which changes no existing table.
|
|
153
|
+
*
|
|
154
|
+
* Only for a run that creates a table (`anyTableMissing()`): a column added to an existing table takes that table's
|
|
155
|
+
* collation, and the switch locks every table of the database. Its wait is bounded, so a first boot behind a long
|
|
156
|
+
* transaction fails with an error instead of freezing the database.
|
|
157
|
+
*/
|
|
158
|
+
export async function withFrameworkDefaultCollation<T>(run: Run, fn: () => Promise<T>): Promise<T> {
|
|
159
|
+
const { rows } = await run({
|
|
160
|
+
params: [],
|
|
161
|
+
text: `SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation
|
|
162
|
+
FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()`,
|
|
163
|
+
});
|
|
164
|
+
const { charset, collation } = (rows[0] ?? {}) as { charset?: string; collation?: string };
|
|
165
|
+
if (collation === FRAMEWORK_COLLATION) {
|
|
166
|
+
return fn();
|
|
167
|
+
}
|
|
168
|
+
if (!/^\w+$/.test(charset ?? '') || !/^\w+$/.test(collation ?? '')) {
|
|
169
|
+
throw new Error(`Cannot read the default collation of the database (found ${charset}/${collation})`);
|
|
170
|
+
}
|
|
171
|
+
const { rows: timeouts } = await run({ params: [], text: 'SELECT @@SESSION.lock_wait_timeout AS timeout' });
|
|
172
|
+
const previousTimeout = Number(timeouts[0]?.timeout);
|
|
173
|
+
await run({ params: [], text: `SET SESSION lock_wait_timeout = ${COLLATION_SWITCH_LOCK_WAIT_SECONDS}` });
|
|
174
|
+
try {
|
|
175
|
+
await run({ params: [], text: `ALTER DATABASE CHARACTER SET utf8mb4 COLLATE ${FRAMEWORK_COLLATION}` });
|
|
176
|
+
try {
|
|
177
|
+
return await fn();
|
|
178
|
+
} finally {
|
|
179
|
+
await run({ params: [], text: `ALTER DATABASE CHARACTER SET ${charset} COLLATE ${collation}` });
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
if (Number.isInteger(previousTimeout) && previousTimeout > 0) {
|
|
183
|
+
await run({ params: [], text: `SET SESSION lock_wait_timeout = ${previousTimeout}` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
112
188
|
/** Apply every automatic change as the OWNER, under the schema lock; refuse when a migration is needed. */
|
|
113
189
|
export async function applyMysqlSchema(
|
|
114
190
|
owner: SqlExecutor,
|
|
@@ -166,6 +242,13 @@ export async function planMysqlSchema(
|
|
|
166
242
|
options.appAccount,
|
|
167
243
|
);
|
|
168
244
|
const changes: SchemaChange[] = [];
|
|
245
|
+
if (tables.some((table) => table.sync) && !catalog.syncCounters) {
|
|
246
|
+
changes.push({
|
|
247
|
+
description: `create the change-order counters ${MYSQL_SYNC_COUNTERS}`,
|
|
248
|
+
kind: 'automatic',
|
|
249
|
+
statements: [mysqlSyncCountersStatement()],
|
|
250
|
+
});
|
|
251
|
+
}
|
|
169
252
|
if (!catalog.granted) {
|
|
170
253
|
changes.push({
|
|
171
254
|
description: `grant ${options.appAccount} SELECT, INSERT, UPDATE, DELETE on database ${catalog.database}`,
|
|
@@ -190,12 +273,53 @@ function planNewTable(table: MysqlDesiredTable): SchemaChange[] {
|
|
|
190
273
|
{
|
|
191
274
|
description: `create table ${table.name} (${table.model.name})`,
|
|
192
275
|
kind: 'automatic',
|
|
193
|
-
statements: [
|
|
276
|
+
statements: [
|
|
277
|
+
createMysqlTableStatement(table),
|
|
278
|
+
...table.indexes.map((index) => index.create),
|
|
279
|
+
...(table.sync ? mysqlSyncTableStatements(table.name, table.sync) : []),
|
|
280
|
+
],
|
|
194
281
|
table: table.name,
|
|
195
282
|
},
|
|
196
283
|
];
|
|
197
284
|
}
|
|
198
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A synchronized table's change order: both triggers of the current version and its counter row. Triggers of an earlier
|
|
288
|
+
* version are replaced; a table that is no longer synchronized keeps its triggers until a migration drops them
|
|
289
|
+
*/
|
|
290
|
+
function planSync(table: MysqlDesiredTable, existing: CatalogTable): SchemaChange[] {
|
|
291
|
+
const name = table.name;
|
|
292
|
+
if (!table.sync) {
|
|
293
|
+
return existing.syncTriggers.size
|
|
294
|
+
? [
|
|
295
|
+
{
|
|
296
|
+
description: `${name} is no longer synchronized; its change-order triggers are kept until a migration drops them`,
|
|
297
|
+
kind: 'notice',
|
|
298
|
+
statements: [],
|
|
299
|
+
table: name,
|
|
300
|
+
},
|
|
301
|
+
]
|
|
302
|
+
: [];
|
|
303
|
+
}
|
|
304
|
+
if (existing.syncTriggerVersion === MYSQL_SYNC_TRIGGER_VERSION) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const current = new Set([table.sync.insertTrigger, table.sync.updateTrigger]);
|
|
308
|
+
return [
|
|
309
|
+
{
|
|
310
|
+
description: `${existing.syncTriggerVersion ? 'replace' : 'add'} the change-order triggers of ${name}`,
|
|
311
|
+
kind: 'automatic',
|
|
312
|
+
statements: [
|
|
313
|
+
...[...existing.syncTriggers]
|
|
314
|
+
.filter((trigger) => !current.has(trigger))
|
|
315
|
+
.map((trigger) => `DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(trigger)}`),
|
|
316
|
+
...mysqlSyncTableStatements(name, table.sync),
|
|
317
|
+
],
|
|
318
|
+
table: name,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
199
323
|
function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): SchemaChange[] {
|
|
200
324
|
const changes: SchemaChange[] = [];
|
|
201
325
|
const name = table.name;
|
|
@@ -255,7 +379,12 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
255
379
|
}
|
|
256
380
|
}
|
|
257
381
|
for (const column of existing.columns.keys()) {
|
|
258
|
-
if (
|
|
382
|
+
if (
|
|
383
|
+
!declared.has(column) &&
|
|
384
|
+
!column.startsWith(NULL_SENTINEL_PREFIX) &&
|
|
385
|
+
!column.startsWith(LIVE_KEY_PREFIX) &&
|
|
386
|
+
!isForeignColumn(name, column)
|
|
387
|
+
) {
|
|
259
388
|
changes.push({
|
|
260
389
|
description: `${name}.${column} is no longer declared; the column is kept until a migration drops it`,
|
|
261
390
|
kind: 'notice',
|
|
@@ -284,16 +413,38 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
284
413
|
}
|
|
285
414
|
const sentinels = (index.sentinels ?? []).filter((sentinel) => deferred.has(sentinel) && !added.has(sentinel));
|
|
286
415
|
sentinels.forEach((sentinel) => added.add(sentinel));
|
|
416
|
+
// A synchronized model's live-key columns read `deleted_at`: an existing model that became synchronized has none
|
|
417
|
+
// yet, and the boot that refuses this index never adds it
|
|
418
|
+
const deletedAt = table.columns.find((column) => column.name === 'deleted_at');
|
|
419
|
+
const needsDeletedAt =
|
|
420
|
+
!!table.sync &&
|
|
421
|
+
!!deletedAt &&
|
|
422
|
+
!existing.columns.has('deleted_at') &&
|
|
423
|
+
!added.has('deleted_at') &&
|
|
424
|
+
sentinels.some((sentinel) => sentinel.startsWith(LIVE_KEY_PREFIX));
|
|
425
|
+
if (needsDeletedAt) {
|
|
426
|
+
added.add('deleted_at');
|
|
427
|
+
}
|
|
428
|
+
// The framework unique index over the same fields that the model no longer declares is the one this live-only index
|
|
429
|
+
// replaces — a tombstone, which keeps no values, would collide on it
|
|
430
|
+
const stem = indexStem(index.name);
|
|
431
|
+
const superseded = table.sync
|
|
432
|
+
? [...existing.indexes].filter(([old, unique]) => unique && !wanted.has(old) && indexStem(old) === stem)
|
|
433
|
+
: [];
|
|
287
434
|
changes.push({
|
|
288
435
|
description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
|
|
289
436
|
// A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
|
|
290
437
|
kind: index.unique ? 'migration' : 'automatic',
|
|
291
438
|
statements: [
|
|
439
|
+
...(needsDeletedAt
|
|
440
|
+
? [`ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition({ ...deletedAt!, primaryKey: false })}`]
|
|
441
|
+
: []),
|
|
292
442
|
...sentinels.map(
|
|
293
443
|
(sentinel) =>
|
|
294
444
|
`ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition(table.columns.find((column) => column.name === sentinel)!)}`,
|
|
295
445
|
),
|
|
296
446
|
index.create,
|
|
447
|
+
...superseded.map(([old]) => `DROP INDEX ${quoteMysqlIdentifier(old)} ON ${quoted}`),
|
|
297
448
|
],
|
|
298
449
|
table: name,
|
|
299
450
|
});
|
|
@@ -308,9 +459,15 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
308
459
|
});
|
|
309
460
|
}
|
|
310
461
|
}
|
|
462
|
+
changes.push(...planSync(table, existing));
|
|
311
463
|
return changes;
|
|
312
464
|
}
|
|
313
465
|
|
|
466
|
+
/** A framework index name without its definition hash (`ux_<table>__<fields>_<hash>`) */
|
|
467
|
+
function indexStem(name: string): string {
|
|
468
|
+
return name.replace(/_[0-9a-f]{8}$/, '');
|
|
469
|
+
}
|
|
470
|
+
|
|
314
471
|
function sameType(column: MysqlDesiredColumn, current: { collation: null | string; type: string }): boolean {
|
|
315
472
|
if (column.type !== current.type) {
|
|
316
473
|
return false;
|
|
@@ -368,20 +525,48 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
|
|
|
368
525
|
text: `SELECT PRIVILEGE_TYPE AS privilege FROM information_schema.SCHEMA_PRIVILEGES
|
|
369
526
|
WHERE GRANTEE = $1 AND TABLE_SCHEMA = DATABASE()`,
|
|
370
527
|
});
|
|
528
|
+
const triggerResult = await run({
|
|
529
|
+
params: tables,
|
|
530
|
+
text: `SELECT EVENT_OBJECT_TABLE AS t, TRIGGER_NAME AS name FROM information_schema.TRIGGERS
|
|
531
|
+
WHERE TRIGGER_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE IN (${list}) AND TRIGGER_NAME LIKE 'lt\\_sync\\_%'`,
|
|
532
|
+
});
|
|
533
|
+
const countersResult = await run({
|
|
534
|
+
params: [MYSQL_SYNC_COUNTERS],
|
|
535
|
+
text: `SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = $1`,
|
|
536
|
+
});
|
|
537
|
+
const counterRows = Number(countersResult.rows[0]?.n)
|
|
538
|
+
? (
|
|
539
|
+
await run({
|
|
540
|
+
params: tables,
|
|
541
|
+
text: `SELECT \`model\` AS t, \`triggers\` AS version FROM ${quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS)} WHERE \`model\` IN (${list})`,
|
|
542
|
+
})
|
|
543
|
+
).rows
|
|
544
|
+
: [];
|
|
371
545
|
|
|
372
546
|
const catalog: Catalog = {
|
|
373
547
|
database,
|
|
374
548
|
granted: DML.every((privilege) => grantResult.rows.some((row) => row.privilege === privilege)),
|
|
549
|
+
syncCounters: Number(countersResult.rows[0]?.n) > 0,
|
|
375
550
|
tables: new Map(),
|
|
376
551
|
};
|
|
377
552
|
const table = (name: string) => {
|
|
378
553
|
let entry = catalog.tables.get(name);
|
|
379
554
|
if (!entry) {
|
|
380
|
-
entry = {
|
|
555
|
+
entry = {
|
|
556
|
+
checks: new Set(),
|
|
557
|
+
columns: new Map(),
|
|
558
|
+
indexes: new Map(),
|
|
559
|
+
syncTriggers: new Set(),
|
|
560
|
+
syncTriggerVersion: null,
|
|
561
|
+
};
|
|
381
562
|
catalog.tables.set(name, entry);
|
|
382
563
|
}
|
|
383
564
|
return entry;
|
|
384
565
|
};
|
|
566
|
+
for (const row of triggerResult.rows) {
|
|
567
|
+
table(row.t).syncTriggers.add(row.name);
|
|
568
|
+
}
|
|
569
|
+
|
|
385
570
|
for (const row of columnResult.rows) {
|
|
386
571
|
table(row.t).columns.set(row.c, {
|
|
387
572
|
collation: row.collation ?? null,
|
|
@@ -395,6 +580,12 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
|
|
|
395
580
|
for (const row of checkResult.rows) {
|
|
396
581
|
table(row.t).checks.add(row.name);
|
|
397
582
|
}
|
|
583
|
+
// After the columns: only a table that exists has a counter row worth reading
|
|
584
|
+
for (const row of counterRows) {
|
|
585
|
+
if (catalog.tables.has(row.t)) {
|
|
586
|
+
table(row.t).syncTriggerVersion = row.version ?? null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
398
589
|
return catalog;
|
|
399
590
|
}
|
|
400
591
|
|
|
@@ -2,6 +2,7 @@ import type { FieldDescriptor, IndexDescriptor, ModelDescriptor } from '../../mo
|
|
|
2
2
|
import { UnsupportedQueryError } from '../sql-errors.js';
|
|
3
3
|
import { columnName, quoteIdentifier, SqlLiterals, sqlLiteral } from '../sql-naming.js';
|
|
4
4
|
import { schemaHash, shortName, tableName } from '../sql-schema.shared.js';
|
|
5
|
+
import { isLiveFilter, SYNC_SEQUENCE_FIELD } from '../../sync-model.js';
|
|
5
6
|
import { compilePostgresWhere } from './postgres-where.compiler.js';
|
|
6
7
|
|
|
7
8
|
export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
|
|
@@ -23,6 +24,15 @@ export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
|
|
|
23
24
|
/** Any string item that starts with `s_` after leading whitespace, case-insensitive (`looksLikeSystemRole`) */
|
|
24
25
|
const SYSTEM_ROLE_PATH = '$[*] ? (@.type() == "string" && @ like_regex "^\\\\s*s_" flag "i")';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Bumped whenever the change-order trigger (`lt_sync_stamp`) changes, so the migrator replaces the function and
|
|
29
|
+
* every table's trigger — the trigger's name carries the version, because a trigger has no comment the catalog query
|
|
30
|
+
* could read alongside the table
|
|
31
|
+
*/
|
|
32
|
+
export const SYNC_TRIGGER_VERSION = 'lt:sync:v1';
|
|
33
|
+
export const SYNC_STAMP_FUNCTION = 'lt_sync_stamp';
|
|
34
|
+
export const SYNC_TRIGGER = 'lt_sync_stamp_v1';
|
|
35
|
+
|
|
26
36
|
/** Bumped whenever the generated guard function or policies change, so the migrator replaces them */
|
|
27
37
|
export const RLS_VERSION = 'lt:tenant:v1';
|
|
28
38
|
export const TENANT_FILTER_FUNCTION = 'lt_tenant_filter';
|
|
@@ -41,6 +51,12 @@ export interface DesiredColumn {
|
|
|
41
51
|
renamedFrom?: string;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/** The objects that number a synchronized model's changes (offline-sync plan §3.4) */
|
|
55
|
+
export interface DesiredSync {
|
|
56
|
+
/** The table's sequence: `nextval()` under the model's advisory lock gives commit order */
|
|
57
|
+
sequence: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
export interface DesiredIndex {
|
|
45
61
|
/** The CREATE statement */
|
|
46
62
|
create: string;
|
|
@@ -61,6 +77,8 @@ export interface DesiredTable {
|
|
|
61
77
|
name: string;
|
|
62
78
|
/** What the model declares but the table cannot carry (text indexes, …), reported once */
|
|
63
79
|
notices: string[];
|
|
80
|
+
/** Present for a synchronized model: its change-order sequence and trigger */
|
|
81
|
+
sync?: DesiredSync;
|
|
64
82
|
/** Row-level security on `tenant_id` */
|
|
65
83
|
tenantIsolated: boolean;
|
|
66
84
|
}
|
|
@@ -119,7 +137,13 @@ export function desiredTable(model: ModelDescriptor, options: PostgresSchemaOpti
|
|
|
119
137
|
indexes.push(desiredIndex(model, name, { fields: { tenantId: 1 }, options: {} }) as DesiredIndex);
|
|
120
138
|
}
|
|
121
139
|
|
|
122
|
-
|
|
140
|
+
const sync = model.sync && model.fields[SYNC_SEQUENCE_FIELD] ? { sequence: syncSequenceName(name) } : undefined;
|
|
141
|
+
return { checks, columns, indexes, model, name, notices, ...(sync ? { sync } : {}), tenantIsolated };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The change-order sequence of a synchronized model's table */
|
|
145
|
+
export function syncSequenceName(table: string): string {
|
|
146
|
+
return shortName('lt_sync', table, ['seq']);
|
|
123
147
|
}
|
|
124
148
|
|
|
125
149
|
function desiredColumn(field: FieldDescriptor): DesiredColumn {
|
|
@@ -223,7 +247,9 @@ function desiredIndex(model: ModelDescriptor, table: string, index: IndexDescrip
|
|
|
223
247
|
const using = gin.length
|
|
224
248
|
? ` USING gin (${gin[0].expression} jsonb_path_ops)`
|
|
225
249
|
: ` (${parts.map((part) => part.sql).join(', ')})`;
|
|
226
|
-
|
|
250
|
+
// A partial index admits NULL repeatedly unless told otherwise. That is harmless where the condition excludes NULL
|
|
251
|
+
// (sparse), but a synchronized model's live-record condition keeps MongoDB's rule: a second live NULL collides
|
|
252
|
+
const nulls = unique && (!conditions.length || (!sparse && isLiveFilter(partial))) ? ' NULLS NOT DISTINCT' : '';
|
|
227
253
|
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
228
254
|
return {
|
|
229
255
|
create: `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(table)}${using}${nulls}${where}`,
|
|
@@ -280,6 +306,52 @@ export function grantStatement(table: string, appRole: string): string {
|
|
|
280
306
|
return `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoteIdentifier(table)} TO ${quoteIdentifier(appRole)}`;
|
|
281
307
|
}
|
|
282
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The trigger function that numbers a synchronized model's writes (offline-sync plan §3.4, C1d; DEV-3900 measured
|
|
311
|
+
* the advisory lock against a counter row). A BEFORE ROW trigger runs after the row is locked, so a write takes its
|
|
312
|
+
* row first and the order last; the transaction-scoped advisory lock is released only after the commit is visible,
|
|
313
|
+
* so sequence order is commit order, and `nextval()` leaves no row version a long snapshot could pile up.
|
|
314
|
+
*
|
|
315
|
+
* A value the statement sets itself is kept: the store's multi-row writes lock their rows, take their numbers and set
|
|
316
|
+
* them explicitly in their final statement.
|
|
317
|
+
*/
|
|
318
|
+
export function syncFunctionStatements(): string[] {
|
|
319
|
+
const fn = quoteIdentifier(SYNC_STAMP_FUNCTION);
|
|
320
|
+
return [
|
|
321
|
+
`CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger
|
|
322
|
+
LANGUAGE plpgsql SET search_path = pg_catalog AS $lt$
|
|
323
|
+
BEGIN
|
|
324
|
+
IF TG_OP = 'INSERT' THEN
|
|
325
|
+
IF NEW.sync_seq IS NOT NULL THEN
|
|
326
|
+
RETURN NEW;
|
|
327
|
+
END IF;
|
|
328
|
+
ELSIF NEW.sync_seq IS DISTINCT FROM OLD.sync_seq THEN
|
|
329
|
+
RETURN NEW;
|
|
330
|
+
END IF;
|
|
331
|
+
PERFORM pg_advisory_xact_lock(hashtext('lenne.tech:sync:' || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME));
|
|
332
|
+
NEW.sync_seq := nextval(format('%I.%I', TG_TABLE_SCHEMA, TG_ARGV[0])::regclass);
|
|
333
|
+
RETURN NEW;
|
|
334
|
+
END
|
|
335
|
+
$lt$`,
|
|
336
|
+
`COMMENT ON FUNCTION ${fn}() IS ${sqlLiteral(SYNC_TRIGGER_VERSION)}`,
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A synchronized table's sequence, the application role's use of it, the trigger, and the numbering of rows written
|
|
342
|
+
* before the table was synchronized (in the order of their ids, i.e. of their creation)
|
|
343
|
+
*/
|
|
344
|
+
export function syncTableStatements(table: string, sync: DesiredSync, appRole: string): string[] {
|
|
345
|
+
const quoted = quoteIdentifier(table);
|
|
346
|
+
const sequence = quoteIdentifier(sync.sequence);
|
|
347
|
+
return [
|
|
348
|
+
`CREATE SEQUENCE IF NOT EXISTS ${sequence}`,
|
|
349
|
+
`GRANT USAGE ON SEQUENCE ${sequence} TO ${quoteIdentifier(appRole)}`,
|
|
350
|
+
`CREATE TRIGGER ${quoteIdentifier(SYNC_TRIGGER)} BEFORE INSERT OR UPDATE ON ${quoted} FOR EACH ROW EXECUTE FUNCTION ${quoteIdentifier(SYNC_STAMP_FUNCTION)}(${sqlLiteral(sync.sequence)})`,
|
|
351
|
+
`UPDATE ${quoted} AS t SET "sync_seq" = n.seq FROM (SELECT "id", nextval(${sqlLiteral(sync.sequence)}::regclass) AS seq FROM (SELECT "id" FROM ${quoted} WHERE "sync_seq" IS NULL ORDER BY "id") AS o) AS n WHERE t."id" = n."id"`,
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
283
355
|
/**
|
|
284
356
|
* The guard every tenant policy calls. It reads the scope the store wrote into the transaction (`sql-session.ts`)
|
|
285
357
|
* and RAISES `42501` without one, so a query on a tenant table outside the framework's context — a forgotten scope,
|
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
grantStatement,
|
|
19
19
|
type PostgresSchemaOptions,
|
|
20
20
|
RLS_VERSION,
|
|
21
|
+
SYNC_STAMP_FUNCTION,
|
|
22
|
+
SYNC_TRIGGER,
|
|
23
|
+
SYNC_TRIGGER_VERSION,
|
|
24
|
+
syncFunctionStatements,
|
|
25
|
+
syncTableStatements,
|
|
21
26
|
TENANT_FILTER_FUNCTION,
|
|
22
27
|
TENANT_POLICY,
|
|
23
28
|
tenantFilterFunctionStatements,
|
|
@@ -63,10 +68,15 @@ interface CatalogTable {
|
|
|
63
68
|
granted: boolean;
|
|
64
69
|
policyVersion: null | string;
|
|
65
70
|
rls: boolean;
|
|
71
|
+
/** Change-order triggers (`lt_sync_*`) on the table */
|
|
72
|
+
syncTriggers: Set<string>;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
interface Catalog {
|
|
69
76
|
functionVersion: null | string;
|
|
77
|
+
/** Sequences of synchronized tables → whether the application role may use them */
|
|
78
|
+
sequences: Map<string, boolean>;
|
|
79
|
+
syncFunctionVersion: null | string;
|
|
70
80
|
schema: string;
|
|
71
81
|
/** Whether the application role may use the schema */
|
|
72
82
|
schemaUsage: boolean;
|
|
@@ -124,9 +134,18 @@ export async function planPostgresSchema(
|
|
|
124
134
|
run,
|
|
125
135
|
tables.map((table) => table.name),
|
|
126
136
|
options.appRole,
|
|
137
|
+
tables.flatMap((table) => (table.sync ? [table.sync.sequence] : [])),
|
|
127
138
|
);
|
|
128
139
|
const changes: SchemaChange[] = [];
|
|
129
140
|
|
|
141
|
+
if (tables.some((table) => table.sync) && catalog.syncFunctionVersion !== SYNC_TRIGGER_VERSION) {
|
|
142
|
+
changes.push({
|
|
143
|
+
description: `${catalog.syncFunctionVersion ? 'update' : 'create'} the change-order function ${SYNC_STAMP_FUNCTION}()`,
|
|
144
|
+
kind: 'automatic',
|
|
145
|
+
statements: syncFunctionStatements(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
if (tables.some((table) => table.tenantIsolated) && catalog.functionVersion !== RLS_VERSION) {
|
|
131
150
|
changes.push({
|
|
132
151
|
description: `${catalog.functionVersion ? 'update' : 'create'} the tenant guard function ${TENANT_FILTER_FUNCTION}()`,
|
|
@@ -146,7 +165,9 @@ export async function planPostgresSchema(
|
|
|
146
165
|
changes.push({ description: notice, kind: 'notice', statements: [], table: table.name });
|
|
147
166
|
}
|
|
148
167
|
const existing = catalog.tables.get(table.name);
|
|
149
|
-
changes.push(
|
|
168
|
+
changes.push(
|
|
169
|
+
...(existing ? planExistingTable(table, existing, catalog.sequences, options) : planNewTable(table, options)),
|
|
170
|
+
);
|
|
150
171
|
}
|
|
151
172
|
return changes;
|
|
152
173
|
}
|
|
@@ -161,6 +182,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
|
|
|
161
182
|
...table.indexes.map((index) => index.create),
|
|
162
183
|
...(table.tenantIsolated ? tenantPolicyStatements(table.name) : []),
|
|
163
184
|
grantStatement(table.name, options.appRole),
|
|
185
|
+
...(table.sync ? syncTableStatements(table.name, table.sync, options.appRole) : []),
|
|
164
186
|
],
|
|
165
187
|
table: table.name,
|
|
166
188
|
},
|
|
@@ -170,6 +192,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
|
|
|
170
192
|
function planExistingTable(
|
|
171
193
|
table: DesiredTable,
|
|
172
194
|
existing: CatalogTable,
|
|
195
|
+
sequences: Map<string, boolean>,
|
|
173
196
|
options: PostgresSchemaOptions,
|
|
174
197
|
): SchemaChange[] {
|
|
175
198
|
const changes: SchemaChange[] = [];
|
|
@@ -249,7 +272,7 @@ function planExistingTable(
|
|
|
249
272
|
description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
|
|
250
273
|
// A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
|
|
251
274
|
kind: index.unique ? 'migration' : 'automatic',
|
|
252
|
-
statements: [index.create],
|
|
275
|
+
statements: index.unique ? liveUniqueMigration(table, existing, index, wanted) : [index.create],
|
|
253
276
|
table: name,
|
|
254
277
|
});
|
|
255
278
|
}
|
|
@@ -294,9 +317,97 @@ function planExistingTable(
|
|
|
294
317
|
table: name,
|
|
295
318
|
});
|
|
296
319
|
}
|
|
320
|
+
changes.push(...planSync(table, existing, sequences, options));
|
|
297
321
|
return changes;
|
|
298
322
|
}
|
|
299
323
|
|
|
324
|
+
/**
|
|
325
|
+
* A synchronized table's change order: the trigger of the current version, its sequence and the application role's
|
|
326
|
+
* use of it. A trigger of an earlier version is replaced; a table that is no longer synchronized keeps its trigger
|
|
327
|
+
* (and its numbering) until a migration removes it
|
|
328
|
+
*/
|
|
329
|
+
function planSync(
|
|
330
|
+
table: DesiredTable,
|
|
331
|
+
existing: CatalogTable,
|
|
332
|
+
sequences: Map<string, boolean>,
|
|
333
|
+
options: PostgresSchemaOptions,
|
|
334
|
+
): SchemaChange[] {
|
|
335
|
+
const name = table.name;
|
|
336
|
+
const quoted = quoteIdentifier(name);
|
|
337
|
+
if (!table.sync) {
|
|
338
|
+
return existing.syncTriggers.size
|
|
339
|
+
? [
|
|
340
|
+
{
|
|
341
|
+
description: `${name} is no longer synchronized; its change-order trigger is kept until a migration drops it`,
|
|
342
|
+
kind: 'notice',
|
|
343
|
+
statements: [],
|
|
344
|
+
table: name,
|
|
345
|
+
},
|
|
346
|
+
]
|
|
347
|
+
: [];
|
|
348
|
+
}
|
|
349
|
+
if (!existing.syncTriggers.has(SYNC_TRIGGER)) {
|
|
350
|
+
const outdated = [...existing.syncTriggers].map(
|
|
351
|
+
(trigger) => `DROP TRIGGER IF EXISTS ${quoteIdentifier(trigger)} ON ${quoted}`,
|
|
352
|
+
);
|
|
353
|
+
return [
|
|
354
|
+
{
|
|
355
|
+
description: `${outdated.length ? 'replace' : 'add'} the change-order trigger of ${name}`,
|
|
356
|
+
kind: 'automatic',
|
|
357
|
+
statements: [...outdated, ...syncTableStatements(name, table.sync, options.appRole)],
|
|
358
|
+
table: name,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
if (!sequences.get(table.sync.sequence)) {
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
description: `let ${options.appRole} use the change-order sequence of ${name}`,
|
|
366
|
+
kind: 'automatic',
|
|
367
|
+
statements: syncTableStatements(name, table.sync, options.appRole).slice(0, 2),
|
|
368
|
+
table: name,
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The statements of a new unique index's migration. A synchronized model's index reads `deleted_at`: when the table
|
|
377
|
+
* does not have it yet (an existing model that became synchronized), the column comes first — the boot that refuses the
|
|
378
|
+
* index never adds it. A framework unique index over the same fields that the model no longer declares is the one the
|
|
379
|
+
* live-only index replaces: it goes last, because a tombstone (which keeps no values) would collide on it
|
|
380
|
+
*/
|
|
381
|
+
function liveUniqueMigration(
|
|
382
|
+
table: DesiredTable,
|
|
383
|
+
existing: CatalogTable,
|
|
384
|
+
index: DesiredTable['indexes'][number],
|
|
385
|
+
wanted: Set<string>,
|
|
386
|
+
): string[] {
|
|
387
|
+
const statements: string[] = [];
|
|
388
|
+
const deletedAt = table.columns.find((column) => column.name === 'deleted_at');
|
|
389
|
+
if (table.sync && deletedAt && !existing.columns.has('deleted_at') && index.create.includes('"deleted_at"')) {
|
|
390
|
+
statements.push(
|
|
391
|
+
`ALTER TABLE ${quoteIdentifier(table.name)} ADD COLUMN ${columnDefinition({ ...deletedAt, primaryKey: false })}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
statements.push(index.create);
|
|
395
|
+
if (table.sync) {
|
|
396
|
+
const stem = indexStem(index.name);
|
|
397
|
+
for (const [old, unique] of existing.indexes) {
|
|
398
|
+
if (unique && !wanted.has(old) && indexStem(old) === stem) {
|
|
399
|
+
statements.push(`DROP INDEX IF EXISTS ${quoteIdentifier(old)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return statements;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A framework index name without its definition hash (`ux_<table>__<fields>_<hash>`) */
|
|
407
|
+
function indexStem(name: string): string {
|
|
408
|
+
return name.replace(/_[0-9a-f]{8}$/, '');
|
|
409
|
+
}
|
|
410
|
+
|
|
300
411
|
function sameType(
|
|
301
412
|
column: DesiredTable['columns'][number],
|
|
302
413
|
current: { collation: null | string; type: string },
|
|
@@ -321,7 +432,7 @@ function typeChange(
|
|
|
321
432
|
};
|
|
322
433
|
}
|
|
323
434
|
|
|
324
|
-
async function readCatalog(run: Run, tables: string[], appRole: string): Promise<Catalog> {
|
|
435
|
+
async function readCatalog(run: Run, tables: string[], appRole: string, sequences: string[]): Promise<Catalog> {
|
|
325
436
|
// One query after the other: inside the migration transaction they share a single connection
|
|
326
437
|
const schemaResult = await run({
|
|
327
438
|
params: [appRole],
|
|
@@ -359,14 +470,31 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
|
|
|
359
470
|
WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND k.contype = 'c'`,
|
|
360
471
|
});
|
|
361
472
|
const functionResult = await run({
|
|
362
|
-
params: [TENANT_FILTER_FUNCTION],
|
|
363
|
-
text: `SELECT obj_description(p.oid, 'pg_proc') AS version
|
|
473
|
+
params: [[TENANT_FILTER_FUNCTION, SYNC_STAMP_FUNCTION]],
|
|
474
|
+
text: `SELECT p.proname AS name, obj_description(p.oid, 'pg_proc') AS version
|
|
364
475
|
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
365
|
-
WHERE n.nspname = current_schema() AND p.proname = $1 AND p.pronargs = 0`,
|
|
476
|
+
WHERE n.nspname = current_schema() AND p.proname = ANY($1) AND p.pronargs = 0`,
|
|
366
477
|
});
|
|
478
|
+
const triggerResult = await run({
|
|
479
|
+
params: [tables],
|
|
480
|
+
text: `SELECT c.relname AS table, t.tgname AS name
|
|
481
|
+
FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
482
|
+
WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND NOT t.tgisinternal AND t.tgname LIKE 'lt\\_sync\\_%'`,
|
|
483
|
+
});
|
|
484
|
+
const sequenceResult = sequences.length
|
|
485
|
+
? await run({
|
|
486
|
+
params: [sequences, appRole],
|
|
487
|
+
text: `SELECT c.relname AS name, has_sequence_privilege($2, c.oid, 'USAGE') AS usage
|
|
488
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
489
|
+
WHERE n.nspname = current_schema() AND c.relkind = 'S' AND c.relname = ANY($1)`,
|
|
490
|
+
})
|
|
491
|
+
: { rowCount: 0, rows: [] };
|
|
492
|
+
const versionOf = (fn: string) => functionResult.rows.find((row) => row.name === fn)?.version ?? null;
|
|
367
493
|
|
|
368
494
|
const catalog: Catalog = {
|
|
369
|
-
functionVersion:
|
|
495
|
+
functionVersion: versionOf(TENANT_FILTER_FUNCTION),
|
|
496
|
+
sequences: new Map(sequenceResult.rows.map((row: { name: string; usage: boolean }) => [row.name, row.usage])),
|
|
497
|
+
syncFunctionVersion: versionOf(SYNC_STAMP_FUNCTION),
|
|
370
498
|
schema: schemaResult.rows[0].schema,
|
|
371
499
|
schemaUsage: schemaResult.rows[0].usage,
|
|
372
500
|
tables: new Map(),
|
|
@@ -380,8 +508,12 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
|
|
|
380
508
|
indexes: new Map(),
|
|
381
509
|
policyVersion: row.policy_version ?? null,
|
|
382
510
|
rls: row.rls,
|
|
511
|
+
syncTriggers: new Set(),
|
|
383
512
|
});
|
|
384
513
|
}
|
|
514
|
+
for (const row of triggerResult.rows) {
|
|
515
|
+
catalog.tables.get(row.table)?.syncTriggers.add(row.name);
|
|
516
|
+
}
|
|
385
517
|
for (const row of columnResult.rows) {
|
|
386
518
|
catalog.tables.get(row.table)?.columns.set(row.name, { collation: row.collation ?? null, type: row.type });
|
|
387
519
|
}
|