@lenne.tech/nest-server 12.1.1 → 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/configurable-features.md +1 -0
- package/.claude/rules/package-management.md +16 -1
- package/.claude/rules/persistence.md +26 -0
- package/.claude/rules/testing.md +79 -14
- package/CLAUDE.md +2 -1
- package/FRAMEWORK-API.md +17 -2
- package/README.md +18 -0
- 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 +4 -2
- 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/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.js +101 -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-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/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/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/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 +4 -0
- 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 +1 -0
- package/docs/offline-sync-protocol.md +96 -62
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/package.json +4 -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 +5 -2
- 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/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 +129 -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-errors.ts +5 -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/crud.service.ts +257 -19
- package/src/core/modules/error-code/error-codes.ts +59 -0
- 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/core-tenant.guard.ts +8 -0
- 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
|
@@ -2,8 +2,10 @@ import { BadRequestException, Logger } from '@nestjs/common';
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
DataStoreUpdate,
|
|
5
|
+
DeleteOptions,
|
|
5
6
|
FindOptions,
|
|
6
7
|
PushOptions,
|
|
8
|
+
ReadOptions,
|
|
7
9
|
StoreCapabilities,
|
|
8
10
|
StoredRecord,
|
|
9
11
|
UpdateOptions,
|
|
@@ -21,7 +23,9 @@ import {
|
|
|
21
23
|
assertSelectFields,
|
|
22
24
|
} from '../../store-guards.js';
|
|
23
25
|
import { castFilterValue } from '../sql-cast.js';
|
|
24
|
-
import { POSTGRES_STORE_CAPABILITIES, SqlDataStore, type SqlDataStoreOptions } from '../sql-data-store.js';
|
|
26
|
+
import { POSTGRES_STORE_CAPABILITIES, SqlDataStore, type SqlDataStoreOptions, type SqlRun } from '../sql-data-store.js';
|
|
27
|
+
import { tableName } from '../sql-schema.shared.js';
|
|
28
|
+
import { MYSQL_SYNC_COUNTERS } from './mysql-schema.compiler.js';
|
|
25
29
|
import { DuplicateKeyError, UnsupportedQueryError } from '../sql-errors.js';
|
|
26
30
|
import type { SqlQueryResult, SqlStatement } from '../sql-executor.interface.js';
|
|
27
31
|
import { columnName, quoteMysqlIdentifier, SqlParams } from '../sql-naming.js';
|
|
@@ -71,7 +75,6 @@ type Run = <R = Record<string, any>>(statement: SqlStatement) => Promise<SqlQuer
|
|
|
71
75
|
* gives MongoDB's findOneAndUpdate guarantee without a retry.
|
|
72
76
|
*/
|
|
73
77
|
export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
74
|
-
override readonly capabilities = MYSQL_STORE_CAPABILITIES;
|
|
75
78
|
override readonly driver: string = 'mysql';
|
|
76
79
|
protected override readonly logger = new Logger(MysqlDataStore.name);
|
|
77
80
|
private readonly variant: MysqlVariant;
|
|
@@ -82,13 +85,44 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
82
85
|
this.driver = executor.variant;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
protected override baseCapabilities(): Readonly<StoreCapabilities> {
|
|
89
|
+
return MYSQL_STORE_CAPABILITIES;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every store write of a synchronized model takes its change number last: `FOR UPDATE` locks index gaps here, and an
|
|
94
|
+
* insert waits for them after its BEFORE INSERT trigger ran — a trigger holding the counter meanwhile would deadlock
|
|
95
|
+
* with a multi-row write that locked those gaps and now asks for the counter (measured, DEV-3900 review)
|
|
96
|
+
*/
|
|
97
|
+
protected override numbersLast(): boolean {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A single-column write (pullFromArray, a one-stage push) — in a transaction, so a synchronized row is numbered */
|
|
102
|
+
protected override async updateColumn(id: string, column: string, value: string, params: SqlParams): Promise<void> {
|
|
103
|
+
if (!this.model.sync) {
|
|
104
|
+
return super.updateColumn(id, column, value, params);
|
|
105
|
+
}
|
|
106
|
+
const { $setOnInsert: _insertOnly, ...stamped } = await this.beforeUpdate({});
|
|
107
|
+
const assignments = [`${column} = ${value}`, ...this.compileUpdate(stamped, params)];
|
|
108
|
+
await this.transaction(async (run) => {
|
|
109
|
+
const { rowCount } = await run({
|
|
110
|
+
params: params.values,
|
|
111
|
+
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params)}`,
|
|
112
|
+
});
|
|
113
|
+
if (rowCount) {
|
|
114
|
+
await this.numberPending(run, [id]);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
// -- reads ----------------------------------------------------------------------------------------------
|
|
86
120
|
|
|
87
|
-
override async count(where?: Where<T
|
|
121
|
+
override async count(where?: Where<T>, options: ReadOptions = {}): Promise<number> {
|
|
88
122
|
const params = new SqlParams();
|
|
89
123
|
const { rows } = await this.run<{ n: number | string }>({
|
|
90
124
|
params: params.values,
|
|
91
|
-
text: `SELECT COUNT(*) AS n FROM ${this.table} WHERE ${this.where(where, params)}`,
|
|
125
|
+
text: `SELECT COUNT(*) AS n FROM ${this.table} WHERE ${this.where(where, params, options)}`,
|
|
92
126
|
});
|
|
93
127
|
return Number(rows[0].n);
|
|
94
128
|
}
|
|
@@ -109,10 +143,10 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
109
143
|
return { count: Number(rows[0].n), sum: Number(rows[0].total) };
|
|
110
144
|
}
|
|
111
145
|
|
|
112
|
-
override async distinct(field: string, where?: Where<T
|
|
146
|
+
override async distinct(field: string, where?: Where<T>, options: ReadOptions = {}): Promise<unknown[]> {
|
|
113
147
|
const params = new SqlParams();
|
|
114
148
|
const path = compileMysqlPath(this.variant, this.model, field, params);
|
|
115
|
-
const condition = this.where(where, params);
|
|
149
|
+
const condition = this.where(where, params, options);
|
|
116
150
|
if (path.kind === 'array') {
|
|
117
151
|
const { rows } = await this.run<{ v: unknown }>({
|
|
118
152
|
params: params.values,
|
|
@@ -133,7 +167,7 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
133
167
|
override async find(where?: Where<T>, options: FindOptions = {}): Promise<StoredRecord<T>[]> {
|
|
134
168
|
const sample = options.sample === undefined ? undefined : assertSampleSize(options.sample);
|
|
135
169
|
const params = new SqlParams();
|
|
136
|
-
let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params)}`;
|
|
170
|
+
let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params, options)}`;
|
|
137
171
|
text += this.orderBy(options, params);
|
|
138
172
|
if (options.limit !== undefined) {
|
|
139
173
|
text += ` LIMIT ${params.add(assertNonNegativeInteger(options.limit, 'limit'))}`;
|
|
@@ -153,14 +187,17 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
153
187
|
|
|
154
188
|
override async insert(record: Partial<T>): Promise<StoredRecord<T>> {
|
|
155
189
|
const doc = await this.beforeInsert({ ...(record as Record<string, any>) });
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
190
|
+
return this.withServerId(doc, (prepared) => {
|
|
191
|
+
const params = new SqlParams();
|
|
192
|
+
const { columns, id, values } = this.insertValues(prepared, params);
|
|
193
|
+
return this.transaction(async (run) => {
|
|
194
|
+
await run({
|
|
195
|
+
params: params.values,
|
|
196
|
+
text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')})`,
|
|
197
|
+
});
|
|
198
|
+
await this.numberPending(run, [id]);
|
|
199
|
+
return (await this.readById(run, id))!;
|
|
162
200
|
});
|
|
163
|
-
return (await this.readById(run, id))!;
|
|
164
201
|
});
|
|
165
202
|
}
|
|
166
203
|
|
|
@@ -173,28 +210,36 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
173
210
|
const params = new SqlParams();
|
|
174
211
|
const assignments = this.compileUpdate(patch, params);
|
|
175
212
|
if (!assignments.length) {
|
|
176
|
-
return this.findById(id);
|
|
213
|
+
return this.findById(id, options);
|
|
177
214
|
}
|
|
178
|
-
const condition = this.where({ id } as Where<T>, params);
|
|
215
|
+
const condition = this.where({ id } as Where<T>, params, options);
|
|
179
216
|
return this.transaction(async (run) => {
|
|
180
217
|
const { rowCount } = await run({
|
|
181
218
|
params: params.values,
|
|
182
219
|
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${condition}`,
|
|
183
220
|
});
|
|
184
221
|
// The driver counts MATCHED rows (CLIENT_FOUND_ROWS), so an update that changes nothing still finds its record
|
|
185
|
-
|
|
222
|
+
if (!rowCount) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
await this.numberPending(run, [id]);
|
|
226
|
+
return this.readById(run, id);
|
|
186
227
|
});
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
override async updateOne(
|
|
190
|
-
|
|
230
|
+
override async updateOne(
|
|
231
|
+
where: Where<T>,
|
|
232
|
+
update: DataStoreUpdate<T>,
|
|
233
|
+
options: UpdateOptions = {},
|
|
234
|
+
): Promise<null | StoredRecord<T>> {
|
|
235
|
+
const patch = await this.preparePatch(update, options);
|
|
191
236
|
const params = new SqlParams();
|
|
192
237
|
const assignments = this.compileUpdate(patch, params);
|
|
193
238
|
if (!assignments.length) {
|
|
194
|
-
return this.findOne(where);
|
|
239
|
+
return this.findOne(where, options);
|
|
195
240
|
}
|
|
196
241
|
return this.transaction(async (run) => {
|
|
197
|
-
const id = await this.lockFirst(run, where);
|
|
242
|
+
const id = await this.lockFirst(run, where, options);
|
|
198
243
|
if (id === undefined) {
|
|
199
244
|
return null;
|
|
200
245
|
}
|
|
@@ -202,16 +247,19 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
202
247
|
params: params.values,
|
|
203
248
|
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE \`id\` = ${params.add(id)}`,
|
|
204
249
|
});
|
|
250
|
+
await this.numberPending(run, [id]);
|
|
205
251
|
return this.readById(run, id);
|
|
206
252
|
});
|
|
207
253
|
}
|
|
208
254
|
|
|
209
|
-
override async deleteById(id: string): Promise<null | StoredRecord<T>> {
|
|
210
|
-
return this.
|
|
255
|
+
override async deleteById(id: string, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
|
|
256
|
+
return this.model.sync && !options.hardDelete
|
|
257
|
+
? super.deleteById(id, options)
|
|
258
|
+
: this.deleteFirst({ id } as Where<T>, options);
|
|
211
259
|
}
|
|
212
260
|
|
|
213
|
-
override async deleteOne(where: Where<T
|
|
214
|
-
return this.deleteFirst(where);
|
|
261
|
+
override async deleteOne(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
|
|
262
|
+
return this.model.sync && !options.hardDelete ? super.deleteOne(where, options) : this.deleteFirst(where, options);
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
override async pushToArray(
|
|
@@ -307,6 +355,7 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
307
355
|
return;
|
|
308
356
|
}
|
|
309
357
|
}
|
|
358
|
+
await this.numberPending(run, [id]);
|
|
310
359
|
});
|
|
311
360
|
}
|
|
312
361
|
|
|
@@ -418,6 +467,30 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
418
467
|
return super.fromColumn(field, value);
|
|
419
468
|
}
|
|
420
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The counter row of this table (plan §3.4, C1d — MySQL and MariaDB update it in place, so a long snapshot costs no
|
|
472
|
+
* versions): raised by `count` at once, its row lock held until the transaction ends
|
|
473
|
+
*/
|
|
474
|
+
protected override async takeChangeNumbers(run: SqlRun, count: number): Promise<string[]> {
|
|
475
|
+
const params = new SqlParams();
|
|
476
|
+
const key = tableName(this.model);
|
|
477
|
+
await run({
|
|
478
|
+
params: params.values,
|
|
479
|
+
text: `UPDATE ${this.quote(MYSQL_SYNC_COUNTERS)} SET \`seq\` = \`seq\` + ${params.add(count)} WHERE \`model\` = ${params.add(key)}`,
|
|
480
|
+
});
|
|
481
|
+
const read = new SqlParams();
|
|
482
|
+
const { rows } = await run<{ seq: number | string }>({
|
|
483
|
+
params: read.values,
|
|
484
|
+
text: `SELECT \`seq\` FROM ${this.quote(MYSQL_SYNC_COUNTERS)} WHERE \`model\` = ${read.add(key)}`,
|
|
485
|
+
});
|
|
486
|
+
const last = BigInt(rows[0].seq);
|
|
487
|
+
return Array.from({ length: count }, (_, index) => (last - BigInt(count - 1 - index)).toString());
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
protected override bigintParam(params: SqlParams, value: string): string {
|
|
491
|
+
return params.add(value);
|
|
492
|
+
}
|
|
493
|
+
|
|
421
494
|
/**
|
|
422
495
|
* MySQL / MariaDB errors as the answers the MongoDB path gives for the same situation. Matched by error NUMBER and
|
|
423
496
|
* server: the driver names numbers from MySQL's list, and MariaDB's 4025 (a failed CHECK) is another error there
|
|
@@ -425,7 +498,8 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
425
498
|
protected override mapError(error: unknown): unknown {
|
|
426
499
|
const errno = Number((error as { errno?: unknown })?.errno);
|
|
427
500
|
if (errno === MYSQL_ERRORS.duplicateEntry) {
|
|
428
|
-
|
|
501
|
+
const message = String((error as { message?: unknown })?.message ?? '');
|
|
502
|
+
return new DuplicateKeyError(this.modelName, /for key '(?:[^']*\.)?PRIMARY'/.test(message));
|
|
429
503
|
}
|
|
430
504
|
if (errno === MYSQL_ERRORS.dataTooLong) {
|
|
431
505
|
return new BadRequestException(`${this.modelName}: a value is longer than its column allows`);
|
|
@@ -460,21 +534,25 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
|
|
|
460
534
|
}
|
|
461
535
|
|
|
462
536
|
/** The id of the first match, its row locked until the transaction ends */
|
|
463
|
-
private async lockFirst(
|
|
537
|
+
private async lockFirst(
|
|
538
|
+
run: Run,
|
|
539
|
+
where: Where<T>,
|
|
540
|
+
options: { includeTombstones?: boolean } = {},
|
|
541
|
+
): Promise<string | undefined> {
|
|
464
542
|
const params = new SqlParams();
|
|
465
543
|
const { rows } = await run<{ id: string }>({
|
|
466
544
|
params: params.values,
|
|
467
|
-
text: `SELECT \`id\` FROM ${this.table} WHERE ${this.where(where, params)} LIMIT 1 FOR UPDATE`,
|
|
545
|
+
text: `SELECT \`id\` FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
|
|
468
546
|
});
|
|
469
547
|
return rows[0]?.id;
|
|
470
548
|
}
|
|
471
549
|
|
|
472
|
-
private async deleteFirst(where: Where<T
|
|
550
|
+
private async deleteFirst(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
|
|
473
551
|
return this.transaction(async (run) => {
|
|
474
552
|
const params = new SqlParams();
|
|
475
553
|
const { rows } = await run({
|
|
476
554
|
params: params.values,
|
|
477
|
-
text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params)} LIMIT 1 FOR UPDATE`,
|
|
555
|
+
text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
|
|
478
556
|
});
|
|
479
557
|
if (!rows.length) {
|
|
480
558
|
return null;
|
|
@@ -2,6 +2,7 @@ import type { FieldDescriptor, IndexDescriptor, ModelDescriptor } from '../../mo
|
|
|
2
2
|
import { UnsupportedQueryError } from '../sql-errors.js';
|
|
3
3
|
import { columnName, quoteMysqlIdentifier } from '../sql-naming.js';
|
|
4
4
|
import { schemaHash, shortName, tableName } from '../sql-schema.shared.js';
|
|
5
|
+
import { SYNC_DELETED_FIELD, SYNC_SEQUENCE_FIELD } from '../../sync-model.js';
|
|
5
6
|
import type { MysqlVariant } from './mysql-session.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -48,6 +49,12 @@ export interface MysqlDesiredCheck {
|
|
|
48
49
|
name: string;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** The triggers that number a synchronized model's changes (offline-sync plan §3.4) */
|
|
53
|
+
export interface MysqlDesiredSync {
|
|
54
|
+
insertTrigger: string;
|
|
55
|
+
updateTrigger: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
export interface MysqlDesiredTable {
|
|
52
59
|
checks: MysqlDesiredCheck[];
|
|
53
60
|
columns: MysqlDesiredColumn[];
|
|
@@ -56,14 +63,29 @@ export interface MysqlDesiredTable {
|
|
|
56
63
|
name: string;
|
|
57
64
|
/** What the model declares but the table cannot carry, reported once */
|
|
58
65
|
notices: string[];
|
|
66
|
+
/** Present for a synchronized model: its change-order triggers */
|
|
67
|
+
sync?: MysqlDesiredSync;
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/** The counter rows of every synchronized table (offline-sync plan §3.4, C1d): one row per table, updated in place */
|
|
71
|
+
export const MYSQL_SYNC_COUNTERS = 'lt_sync_counters';
|
|
72
|
+
/**
|
|
73
|
+
* Bumped whenever the change-order triggers change: their names carry the version, so the migrator finds an outdated
|
|
74
|
+
* trigger by name and replaces it
|
|
75
|
+
*/
|
|
76
|
+
export const MYSQL_SYNC_TRIGGER_VERSION = 'v1';
|
|
77
|
+
|
|
61
78
|
/** Text types whose collation is `utf8mb4_bin` */
|
|
62
79
|
const TEXT_TYPES = new Set(['char(36)', 'longtext', 'varchar(255)']);
|
|
63
80
|
/** Kinds stored as strings — the null sentinel of a unique index is a string */
|
|
64
81
|
const STRING_KINDS = new Set(['enum', 'id', 'ref', 'string', 'uuid']);
|
|
65
82
|
/** Prefix of the virtual columns that carry a unique index's null sentinel */
|
|
66
83
|
export const NULL_SENTINEL_PREFIX = 'lt_nn_';
|
|
84
|
+
/**
|
|
85
|
+
* Prefix of the virtual columns a synchronized model's unique index reads: the value while the record is live, NULL
|
|
86
|
+
* once it is a tombstone — MySQL and MariaDB have no partial index, and NULL may repeat in a unique one (plan §3.6)
|
|
87
|
+
*/
|
|
88
|
+
export const LIVE_KEY_PREFIX = 'lt_lk_';
|
|
67
89
|
/** InnoDB's limit for the columns of one index, in bytes (utf8mb4: four per character) */
|
|
68
90
|
const MAX_INDEX_BYTES = 3072;
|
|
69
91
|
const INDEX_BYTES: Record<string, number> = {
|
|
@@ -141,7 +163,14 @@ export function desiredMysqlTable(model: ModelDescriptor, variant: MysqlVariant)
|
|
|
141
163
|
tableIndexes.push(...result);
|
|
142
164
|
}
|
|
143
165
|
}
|
|
144
|
-
|
|
166
|
+
const sync =
|
|
167
|
+
model.sync && model.fields[SYNC_SEQUENCE_FIELD]
|
|
168
|
+
? {
|
|
169
|
+
insertTrigger: shortName(`lt_sync_${MYSQL_SYNC_TRIGGER_VERSION}`, name, ['bi']),
|
|
170
|
+
updateTrigger: shortName(`lt_sync_${MYSQL_SYNC_TRIGGER_VERSION}`, name, ['bu']),
|
|
171
|
+
}
|
|
172
|
+
: undefined;
|
|
173
|
+
return { checks, columns, indexes: tableIndexes, model, name, notices, ...(sync ? { sync } : {}) };
|
|
145
174
|
}
|
|
146
175
|
|
|
147
176
|
export function variantLabel(variant: MysqlVariant): string {
|
|
@@ -248,10 +277,14 @@ function desiredIndexes(
|
|
|
248
277
|
}
|
|
249
278
|
const unique = index.options.unique === true;
|
|
250
279
|
const partial = index.options.partialFilterExpression as Record<string, unknown> | undefined;
|
|
280
|
+
// A synchronized model's unique index applies to live records only: it reads virtual live-key columns
|
|
281
|
+
const live = unique && !!partial && livePartial(partial, entries);
|
|
282
|
+
const liveSparse = live && Object.keys(partial!).length > 1;
|
|
251
283
|
// A partial unique index whose filter only says "every indexed field exists" is a sparse one — MySQL's own NULL
|
|
252
284
|
// handling. Any other filter has no counterpart, and a uniqueness checked nowhere must not pass for one that holds
|
|
253
|
-
const sparse =
|
|
254
|
-
|
|
285
|
+
const sparse =
|
|
286
|
+
!live && (index.options.sparse === true || (unique && !!partial && requiresIndexedFields(partial, entries)));
|
|
287
|
+
if (unique && partial && !sparse && !live) {
|
|
255
288
|
throw new UnsupportedQueryError(
|
|
256
289
|
`${model.name}: the partial unique index ${JSON.stringify(index.fields)} has no MySQL / MariaDB counterpart — ` +
|
|
257
290
|
'uniqueness would be checked nowhere. Use a sparse unique index, or enforce it in the service',
|
|
@@ -273,7 +306,9 @@ function desiredIndexes(
|
|
|
273
306
|
return 'an index over an array, JSON or binary field';
|
|
274
307
|
}
|
|
275
308
|
let target = column.name;
|
|
276
|
-
if (
|
|
309
|
+
if (live && !column.primaryKey) {
|
|
310
|
+
target = liveKeyColumn(column, columns, !liveSparse && STRING_KINDS.has(field.kind) && !field.isArray);
|
|
311
|
+
} else if (unique && !sparse && !column.primaryKey && STRING_KINDS.has(field.kind) && !field.isArray) {
|
|
277
312
|
// A missing value is a value of its own, as in MongoDB: the index reads it from a virtual column
|
|
278
313
|
target = nullSentinelColumn(column, columns);
|
|
279
314
|
}
|
|
@@ -331,6 +366,36 @@ function desiredIndexes(
|
|
|
331
366
|
return result;
|
|
332
367
|
}
|
|
333
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Whether a partial filter is a synchronized model's live-record condition (`{ deletedAt: null }`), alone or with
|
|
371
|
+
* "every indexed field exists" — the rewrite of a sparse unique index (`sync-model.ts` → `liveUniqueIndex()`)
|
|
372
|
+
*/
|
|
373
|
+
function livePartial(partial: Record<string, unknown>, entries: [string, unknown][]): boolean {
|
|
374
|
+
if (!(SYNC_DELETED_FIELD in partial) || partial[SYNC_DELETED_FIELD] !== null) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
const { [SYNC_DELETED_FIELD]: _live, ...rest } = partial;
|
|
378
|
+
return !Object.keys(rest).length || requiresIndexedFields(rest, entries);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The virtual column carrying `column`'s value while the record is live — and NULL for a tombstone, which a unique
|
|
383
|
+
* index lets repeat. `sentinel`: a live NULL becomes the null sentinel, so it collides like a second live value
|
|
384
|
+
*/
|
|
385
|
+
function liveKeyColumn(column: MysqlDesiredColumn, columns: MysqlDesiredColumn[], sentinel: boolean): string {
|
|
386
|
+
const name = `${LIVE_KEY_PREFIX}${column.name}`.slice(0, 63);
|
|
387
|
+
if (!columns.some((candidate) => candidate.name === name)) {
|
|
388
|
+
const value = quoteMysqlIdentifier(column.name);
|
|
389
|
+
columns.push({
|
|
390
|
+
generated: `IF(${quoteMysqlIdentifier(columnName(SYNC_DELETED_FIELD))} IS NULL, ${sentinel ? `IFNULL(${value}, CHAR(0 USING utf8mb4))` : value}, NULL)`,
|
|
391
|
+
name,
|
|
392
|
+
primaryKey: false,
|
|
393
|
+
type: column.type,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return name;
|
|
397
|
+
}
|
|
398
|
+
|
|
334
399
|
/** Whether a partial filter says exactly "every indexed field exists" (`{ field: { $exists: true } }` each) */
|
|
335
400
|
function requiresIndexedFields(partial: Record<string, unknown>, entries: [string, unknown][]): boolean {
|
|
336
401
|
const keys = Object.keys(partial);
|
|
@@ -384,6 +449,44 @@ export function mysqlColumnDefinition(column: MysqlDesiredColumn): string {
|
|
|
384
449
|
return `${quoteMysqlIdentifier(column.name)} ${type}${collation}${key}${fallback}`;
|
|
385
450
|
}
|
|
386
451
|
|
|
452
|
+
/**
|
|
453
|
+
* The counter table, created once for every synchronized table of the database. `triggers` records the version of the
|
|
454
|
+
* table's triggers once they are in place: the application account may not read the triggers themselves (that needs
|
|
455
|
+
* the TRIGGER privilege it must never hold), so a boot without the owner checks this column instead
|
|
456
|
+
*/
|
|
457
|
+
export function mysqlSyncCountersStatement(): string {
|
|
458
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS)} (\`model\` varchar(64) COLLATE utf8mb4_bin NOT NULL PRIMARY KEY, \`seq\` bigint NOT NULL, \`triggers\` varchar(16) COLLATE utf8mb4_bin NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A synchronized table's counter row, its two triggers and the numbering of rows written before it was synchronized.
|
|
463
|
+
*
|
|
464
|
+
* A BEFORE ROW trigger runs after the row is locked, so a write takes its row first and the counter last; the counter
|
|
465
|
+
* row's lock is held until COMMIT, so sequence order is commit order (C1d). A value the statement sets itself is kept
|
|
466
|
+
* — the store's multi-row writes take their numbers and set them in their final statement. The numbering of existing
|
|
467
|
+
* rows runs in an explicit transaction holding the counter row: a DDL statement commits on its own, and a write firing
|
|
468
|
+
* the new trigger meanwhile must wait rather than draw a number twice.
|
|
469
|
+
*/
|
|
470
|
+
export function mysqlSyncTableStatements(table: string, sync: MysqlDesiredSync): string[] {
|
|
471
|
+
const quoted = quoteMysqlIdentifier(table);
|
|
472
|
+
const counters = quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS);
|
|
473
|
+
const key = mysqlLiteral(table);
|
|
474
|
+
const seq = quoteMysqlIdentifier(columnName(SYNC_SEQUENCE_FIELD));
|
|
475
|
+
const take = `UPDATE ${counters} SET \`seq\` = \`seq\` + 1 WHERE \`model\` = ${key}; SET NEW.${seq} = (SELECT \`seq\` FROM ${counters} WHERE \`model\` = ${key});`;
|
|
476
|
+
return [
|
|
477
|
+
`INSERT IGNORE INTO ${counters} (\`model\`, \`seq\`) VALUES (${key}, 0)`,
|
|
478
|
+
`DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(sync.insertTrigger)}`,
|
|
479
|
+
`DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(sync.updateTrigger)}`,
|
|
480
|
+
`CREATE TRIGGER ${quoteMysqlIdentifier(sync.insertTrigger)} BEFORE INSERT ON ${quoted} FOR EACH ROW BEGIN IF NEW.${seq} IS NULL THEN ${take} END IF; END`,
|
|
481
|
+
`CREATE TRIGGER ${quoteMysqlIdentifier(sync.updateTrigger)} BEFORE UPDATE ON ${quoted} FOR EACH ROW BEGIN IF NEW.${seq} <=> OLD.${seq} THEN ${take} END IF; END`,
|
|
482
|
+
'START TRANSACTION',
|
|
483
|
+
`SELECT \`seq\` INTO @lt_sync_base FROM ${counters} WHERE \`model\` = ${key} FOR UPDATE`,
|
|
484
|
+
`UPDATE ${quoted} AS t JOIN (SELECT \`id\`, ROW_NUMBER() OVER (ORDER BY \`id\`) AS n FROM ${quoted} WHERE ${seq} IS NULL) AS o ON o.\`id\` = t.\`id\` SET t.${seq} = @lt_sync_base + o.n`,
|
|
485
|
+
`UPDATE ${counters} SET \`seq\` = GREATEST(\`seq\`, (SELECT COALESCE(MAX(${seq}), 0) FROM ${quoted})), \`triggers\` = ${mysqlLiteral(MYSQL_SYNC_TRIGGER_VERSION)} WHERE \`model\` = ${key}`,
|
|
486
|
+
'COMMIT',
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
387
490
|
export function addMysqlCheckStatement(table: string, check: MysqlDesiredCheck): string {
|
|
388
491
|
return `ALTER TABLE ${quoteMysqlIdentifier(table)} ADD CONSTRAINT ${quoteMysqlIdentifier(check.name)} CHECK (${check.expression})`;
|
|
389
492
|
}
|
|
@@ -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
|
|
|
@@ -231,6 +242,13 @@ export async function planMysqlSchema(
|
|
|
231
242
|
options.appAccount,
|
|
232
243
|
);
|
|
233
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
|
+
}
|
|
234
252
|
if (!catalog.granted) {
|
|
235
253
|
changes.push({
|
|
236
254
|
description: `grant ${options.appAccount} SELECT, INSERT, UPDATE, DELETE on database ${catalog.database}`,
|
|
@@ -255,12 +273,53 @@ function planNewTable(table: MysqlDesiredTable): SchemaChange[] {
|
|
|
255
273
|
{
|
|
256
274
|
description: `create table ${table.name} (${table.model.name})`,
|
|
257
275
|
kind: 'automatic',
|
|
258
|
-
statements: [
|
|
276
|
+
statements: [
|
|
277
|
+
createMysqlTableStatement(table),
|
|
278
|
+
...table.indexes.map((index) => index.create),
|
|
279
|
+
...(table.sync ? mysqlSyncTableStatements(table.name, table.sync) : []),
|
|
280
|
+
],
|
|
259
281
|
table: table.name,
|
|
260
282
|
},
|
|
261
283
|
];
|
|
262
284
|
}
|
|
263
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
|
+
|
|
264
323
|
function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): SchemaChange[] {
|
|
265
324
|
const changes: SchemaChange[] = [];
|
|
266
325
|
const name = table.name;
|
|
@@ -320,7 +379,12 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
320
379
|
}
|
|
321
380
|
}
|
|
322
381
|
for (const column of existing.columns.keys()) {
|
|
323
|
-
if (
|
|
382
|
+
if (
|
|
383
|
+
!declared.has(column) &&
|
|
384
|
+
!column.startsWith(NULL_SENTINEL_PREFIX) &&
|
|
385
|
+
!column.startsWith(LIVE_KEY_PREFIX) &&
|
|
386
|
+
!isForeignColumn(name, column)
|
|
387
|
+
) {
|
|
324
388
|
changes.push({
|
|
325
389
|
description: `${name}.${column} is no longer declared; the column is kept until a migration drops it`,
|
|
326
390
|
kind: 'notice',
|
|
@@ -349,16 +413,38 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
349
413
|
}
|
|
350
414
|
const sentinels = (index.sentinels ?? []).filter((sentinel) => deferred.has(sentinel) && !added.has(sentinel));
|
|
351
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
|
+
: [];
|
|
352
434
|
changes.push({
|
|
353
435
|
description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
|
|
354
436
|
// A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
|
|
355
437
|
kind: index.unique ? 'migration' : 'automatic',
|
|
356
438
|
statements: [
|
|
439
|
+
...(needsDeletedAt
|
|
440
|
+
? [`ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition({ ...deletedAt!, primaryKey: false })}`]
|
|
441
|
+
: []),
|
|
357
442
|
...sentinels.map(
|
|
358
443
|
(sentinel) =>
|
|
359
444
|
`ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition(table.columns.find((column) => column.name === sentinel)!)}`,
|
|
360
445
|
),
|
|
361
446
|
index.create,
|
|
447
|
+
...superseded.map(([old]) => `DROP INDEX ${quoteMysqlIdentifier(old)} ON ${quoted}`),
|
|
362
448
|
],
|
|
363
449
|
table: name,
|
|
364
450
|
});
|
|
@@ -373,9 +459,15 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
|
|
|
373
459
|
});
|
|
374
460
|
}
|
|
375
461
|
}
|
|
462
|
+
changes.push(...planSync(table, existing));
|
|
376
463
|
return changes;
|
|
377
464
|
}
|
|
378
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
|
+
|
|
379
471
|
function sameType(column: MysqlDesiredColumn, current: { collation: null | string; type: string }): boolean {
|
|
380
472
|
if (column.type !== current.type) {
|
|
381
473
|
return false;
|
|
@@ -433,20 +525,48 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
|
|
|
433
525
|
text: `SELECT PRIVILEGE_TYPE AS privilege FROM information_schema.SCHEMA_PRIVILEGES
|
|
434
526
|
WHERE GRANTEE = $1 AND TABLE_SCHEMA = DATABASE()`,
|
|
435
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
|
+
: [];
|
|
436
545
|
|
|
437
546
|
const catalog: Catalog = {
|
|
438
547
|
database,
|
|
439
548
|
granted: DML.every((privilege) => grantResult.rows.some((row) => row.privilege === privilege)),
|
|
549
|
+
syncCounters: Number(countersResult.rows[0]?.n) > 0,
|
|
440
550
|
tables: new Map(),
|
|
441
551
|
};
|
|
442
552
|
const table = (name: string) => {
|
|
443
553
|
let entry = catalog.tables.get(name);
|
|
444
554
|
if (!entry) {
|
|
445
|
-
entry = {
|
|
555
|
+
entry = {
|
|
556
|
+
checks: new Set(),
|
|
557
|
+
columns: new Map(),
|
|
558
|
+
indexes: new Map(),
|
|
559
|
+
syncTriggers: new Set(),
|
|
560
|
+
syncTriggerVersion: null,
|
|
561
|
+
};
|
|
446
562
|
catalog.tables.set(name, entry);
|
|
447
563
|
}
|
|
448
564
|
return entry;
|
|
449
565
|
};
|
|
566
|
+
for (const row of triggerResult.rows) {
|
|
567
|
+
table(row.t).syncTriggers.add(row.name);
|
|
568
|
+
}
|
|
569
|
+
|
|
450
570
|
for (const row of columnResult.rows) {
|
|
451
571
|
table(row.t).columns.set(row.c, {
|
|
452
572
|
collation: row.collation ?? null,
|
|
@@ -460,6 +580,12 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
|
|
|
460
580
|
for (const row of checkResult.rows) {
|
|
461
581
|
table(row.t).checks.add(row.name);
|
|
462
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
|
+
}
|
|
463
589
|
return catalog;
|
|
464
590
|
}
|
|
465
591
|
|