@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
|
@@ -4,14 +4,32 @@ import { ConfigService } from '../../services/config.service.js';
|
|
|
4
4
|
import type {
|
|
5
5
|
DataStore,
|
|
6
6
|
DataStoreUpdate,
|
|
7
|
+
DeleteOptions,
|
|
7
8
|
FindOptions,
|
|
8
9
|
PushOptions,
|
|
10
|
+
ReadOptions,
|
|
9
11
|
StoreCapabilities,
|
|
10
12
|
StoredRecord,
|
|
11
13
|
UpdateOptions,
|
|
12
14
|
Where,
|
|
13
15
|
} from '../data-store.interface.js';
|
|
14
16
|
import { generateId, isWellFormedId } from '../id-format.js';
|
|
17
|
+
import {
|
|
18
|
+
assertClientId,
|
|
19
|
+
generateSyncId,
|
|
20
|
+
insertWithServerId,
|
|
21
|
+
suppliedId,
|
|
22
|
+
syncInsertRecord,
|
|
23
|
+
tombstoneClearedFields,
|
|
24
|
+
} from '../policies/sync.policy.js';
|
|
25
|
+
import {
|
|
26
|
+
liveFilter,
|
|
27
|
+
SYNC_DELETED_FIELD,
|
|
28
|
+
SYNC_SEQUENCE_FIELD,
|
|
29
|
+
SYNC_SEQUENCE_PENDING,
|
|
30
|
+
SYNC_SYSTEM_FIELDS,
|
|
31
|
+
SYNC_VERSION_FIELD,
|
|
32
|
+
} from '../sync-model.js';
|
|
15
33
|
import type { FieldDescriptor, ModelDescriptor } from '../model-descriptor.interface.js';
|
|
16
34
|
import { resolveAuditActorId, stampAuditOnUpdate, stampAuditOnWrite } from '../policies/audit.policy.js';
|
|
17
35
|
import { hashPassword, hashUpdatePassword } from '../policies/password.policy.js';
|
|
@@ -36,6 +54,7 @@ import {
|
|
|
36
54
|
import { computedDefault } from '../mongo/declared-schema.view.js';
|
|
37
55
|
import type { DeclaredSchema } from '../mongo/model-declarations.js';
|
|
38
56
|
import { tableName } from './sql-schema.shared.js';
|
|
57
|
+
import { syncSequenceName } from './postgres/postgres-schema.compiler.js';
|
|
39
58
|
import { compilePostgresUpdate, pullExpression } from './postgres/postgres-update.compiler.js';
|
|
40
59
|
import { compilePostgresPath, compilePostgresWhere } from './postgres/postgres-where.compiler.js';
|
|
41
60
|
import { castFilterValue } from './sql-cast.js';
|
|
@@ -64,6 +83,25 @@ export interface SqlDataStoreOptions {
|
|
|
64
83
|
schema?: DeclaredSchema | { path(name: string): any };
|
|
65
84
|
}
|
|
66
85
|
|
|
86
|
+
/** One statement inside a store transaction */
|
|
87
|
+
export type SqlRun = <R = Record<string, any>>(statement: SqlStatement) => Promise<{ rowCount: number; rows: R[] }>;
|
|
88
|
+
|
|
89
|
+
/** Internal: an update the store itself writes (a tombstone), which may set `deletedAt` */
|
|
90
|
+
interface WriteOptions extends UpdateOptions {
|
|
91
|
+
tombstone?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How many rows one statement of a multi-row write of a synchronized model numbers */
|
|
95
|
+
const SYNC_CHUNK = 500;
|
|
96
|
+
|
|
97
|
+
/** The capabilities a store of this model reports: a synchronized model adds its change order and id format */
|
|
98
|
+
export function storeCapabilities(
|
|
99
|
+
base: Readonly<StoreCapabilities>,
|
|
100
|
+
model: ModelDescriptor,
|
|
101
|
+
): Readonly<StoreCapabilities> {
|
|
102
|
+
return model.sync ? Object.freeze({ ...base, changeOrder: 'seq', idType: model.sync.idFormat }) : base;
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
const SCALAR_KINDS = new Set([
|
|
68
106
|
'bigint',
|
|
69
107
|
'boolean',
|
|
@@ -98,7 +136,7 @@ const UPDATE_ONE_ATTEMPTS = 3;
|
|
|
98
136
|
* table is absent from the record — SQL cannot tell a stored null from an absent value.
|
|
99
137
|
*/
|
|
100
138
|
export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
101
|
-
readonly capabilities: Readonly<StoreCapabilities
|
|
139
|
+
readonly capabilities: Readonly<StoreCapabilities>;
|
|
102
140
|
readonly driver: string = 'postgres';
|
|
103
141
|
protected readonly logger = new Logger(SqlDataStore.name);
|
|
104
142
|
protected readonly table: string;
|
|
@@ -111,23 +149,29 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
111
149
|
protected readonly model: ModelDescriptor,
|
|
112
150
|
protected readonly options: SqlDataStoreOptions = {},
|
|
113
151
|
) {
|
|
152
|
+
this.capabilities = storeCapabilities(this.baseCapabilities(), model);
|
|
114
153
|
this.table = this.quote(tableName(model));
|
|
115
154
|
for (const field of Object.values(model.fields)) {
|
|
116
155
|
this.fields.set(columnName(field.name), field);
|
|
117
156
|
}
|
|
118
157
|
}
|
|
119
158
|
|
|
159
|
+
/** What the database offers through this store, before the model's own additions */
|
|
160
|
+
protected baseCapabilities(): Readonly<StoreCapabilities> {
|
|
161
|
+
return POSTGRES_STORE_CAPABILITIES;
|
|
162
|
+
}
|
|
163
|
+
|
|
120
164
|
get modelName(): string {
|
|
121
165
|
return this.model.name;
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
// -- reads ----------------------------------------------------------------------------------------------
|
|
125
169
|
|
|
126
|
-
async count(where?: Where<T
|
|
170
|
+
async count(where?: Where<T>, options: ReadOptions = {}): Promise<number> {
|
|
127
171
|
const params = new SqlParams();
|
|
128
172
|
const { rows } = await this.run<{ n: number }>({
|
|
129
173
|
params: params.values,
|
|
130
|
-
text: `SELECT count(*)::int AS n FROM ${this.table} WHERE ${this.where(where, params)}`,
|
|
174
|
+
text: `SELECT count(*)::int AS n FROM ${this.table} WHERE ${this.where(where, params, options)}`,
|
|
131
175
|
});
|
|
132
176
|
return rows[0].n;
|
|
133
177
|
}
|
|
@@ -151,10 +195,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
151
195
|
return { count: rows[0].n, sum: Number(rows[0].total) };
|
|
152
196
|
}
|
|
153
197
|
|
|
154
|
-
async distinct(field: string, where?: Where<T
|
|
198
|
+
async distinct(field: string, where?: Where<T>, options: ReadOptions = {}): Promise<unknown[]> {
|
|
155
199
|
const params = new SqlParams();
|
|
156
200
|
const path = compilePostgresPath(this.model, field, params);
|
|
157
|
-
const condition = this.where(where, params);
|
|
201
|
+
const condition = this.where(where, params, options);
|
|
158
202
|
const text =
|
|
159
203
|
path.kind === 'array'
|
|
160
204
|
? `SELECT DISTINCT d.v AS v FROM ${this.table}, jsonb_array_elements(CASE WHEN jsonb_typeof(${path.expr}) = 'array' THEN ${path.expr} ELSE '[]'::jsonb END) AS d(v) WHERE ${condition}`
|
|
@@ -166,7 +210,7 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
166
210
|
async find(where?: Where<T>, options: FindOptions = {}): Promise<StoredRecord<T>[]> {
|
|
167
211
|
const sample = options.sample === undefined ? undefined : assertSampleSize(options.sample);
|
|
168
212
|
const params = new SqlParams();
|
|
169
|
-
let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params)}`;
|
|
213
|
+
let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params, options)}`;
|
|
170
214
|
text += this.orderBy(options, params);
|
|
171
215
|
if (options.limit !== undefined) {
|
|
172
216
|
text += ` LIMIT ${params.add(assertNonNegativeInteger(options.limit, 'limit'))}`;
|
|
@@ -182,7 +226,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
182
226
|
return this.toRecords(rows, options.select);
|
|
183
227
|
}
|
|
184
228
|
|
|
185
|
-
async findById(
|
|
229
|
+
async findById(
|
|
230
|
+
id: string,
|
|
231
|
+
options: Pick<FindOptions, 'exclude' | 'includeTombstones' | 'select'> = {},
|
|
232
|
+
): Promise<null | StoredRecord<T>> {
|
|
186
233
|
return this.findOne({ id } as Where<T>, options);
|
|
187
234
|
}
|
|
188
235
|
|
|
@@ -199,8 +246,14 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
199
246
|
params: SqlParams,
|
|
200
247
|
): { columns: string[]; id: string; values: string[] } {
|
|
201
248
|
const supplied = doc.id ?? doc._id;
|
|
202
|
-
const id =
|
|
203
|
-
|
|
249
|
+
const id = this.model.sync
|
|
250
|
+
? // A synchronized model keeps a client's id in its format and refuses any other (plan §3.3, E6)
|
|
251
|
+
supplied === undefined || supplied === null
|
|
252
|
+
? generateSyncId(this.model.sync.idFormat)
|
|
253
|
+
: assertClientId(supplied, this.model.sync.idFormat, this.modelName)
|
|
254
|
+
: supplied !== undefined && isWellFormedId(String(supplied))
|
|
255
|
+
? String(supplied).toLowerCase()
|
|
256
|
+
: generateId();
|
|
204
257
|
const columns = [this.quote('id')];
|
|
205
258
|
const values = [params.add(id)];
|
|
206
259
|
for (const [key, value] of Object.entries(doc)) {
|
|
@@ -217,13 +270,15 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
217
270
|
|
|
218
271
|
async insert(record: Partial<T>): Promise<StoredRecord<T>> {
|
|
219
272
|
const doc = await this.beforeInsert({ ...(record as Record<string, any>) });
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
273
|
+
return this.withServerId(doc, async (prepared) => {
|
|
274
|
+
const params = new SqlParams();
|
|
275
|
+
const { columns, values } = this.insertValues(prepared, params);
|
|
276
|
+
const { rows } = await this.run({
|
|
277
|
+
params: params.values,
|
|
278
|
+
text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')}) RETURNING ${this.selectList()}`,
|
|
279
|
+
});
|
|
280
|
+
return this.toRecords(rows)[0];
|
|
225
281
|
});
|
|
226
|
-
return this.toRecords(rows)[0];
|
|
227
282
|
}
|
|
228
283
|
|
|
229
284
|
async updateById(
|
|
@@ -235,28 +290,32 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
235
290
|
const params = new SqlParams();
|
|
236
291
|
const assignments = this.compileUpdate(patch, params);
|
|
237
292
|
if (!assignments.length) {
|
|
238
|
-
return this.findById(id);
|
|
293
|
+
return this.findById(id, options);
|
|
239
294
|
}
|
|
240
295
|
const { rows } = await this.run({
|
|
241
296
|
params: params.values,
|
|
242
|
-
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params)} RETURNING ${this.selectList()}`,
|
|
297
|
+
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params, options)} RETURNING ${this.selectList()}`,
|
|
243
298
|
});
|
|
244
299
|
return this.toRecords(rows)[0] ?? null;
|
|
245
300
|
}
|
|
246
301
|
|
|
247
|
-
async updateOne(
|
|
248
|
-
|
|
302
|
+
async updateOne(
|
|
303
|
+
where: Where<T>,
|
|
304
|
+
update: DataStoreUpdate<T>,
|
|
305
|
+
options: WriteOptions = {},
|
|
306
|
+
): Promise<null | StoredRecord<T>> {
|
|
307
|
+
const patch = await this.preparePatch(update, options);
|
|
249
308
|
for (let attempt = 1; ; attempt++) {
|
|
250
309
|
const params = new SqlParams();
|
|
251
310
|
const assignments = this.compileUpdate(patch, params);
|
|
252
311
|
if (!assignments.length) {
|
|
253
|
-
return this.findOne(where);
|
|
312
|
+
return this.findOne(where, options);
|
|
254
313
|
}
|
|
255
314
|
// The sub-select locks the row it picks, and a row a concurrent caller changed meanwhile is re-checked and
|
|
256
315
|
// skipped for the next match; the condition is repeated outside so the row provably still matches when it is
|
|
257
316
|
// written — the guarantee of MongoDB's findOneAndUpdate
|
|
258
|
-
const match = this.where(where, params);
|
|
259
|
-
const recheck = this.where(where, params);
|
|
317
|
+
const match = this.where(where, params, options);
|
|
318
|
+
const recheck = this.where(where, params, options);
|
|
260
319
|
const { rows } = await this.run({
|
|
261
320
|
params: params.values,
|
|
262
321
|
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE "id" = (SELECT "id" FROM ${this.table} WHERE ${match} LIMIT 1 FOR UPDATE) AND ${recheck} RETURNING ${this.selectList()}`,
|
|
@@ -266,35 +325,44 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
266
325
|
}
|
|
267
326
|
// Nothing written: nothing matches any more — or the chosen row stopped matching between the pick and the write
|
|
268
327
|
// while another one still does. Only the second is retried
|
|
269
|
-
if (attempt >= UPDATE_ONE_ATTEMPTS || !(await this.exists(where))) {
|
|
328
|
+
if (attempt >= UPDATE_ONE_ATTEMPTS || !(await this.exists(where, options))) {
|
|
270
329
|
return null;
|
|
271
330
|
}
|
|
272
331
|
}
|
|
273
332
|
}
|
|
274
333
|
|
|
275
|
-
async deleteById(id: string): Promise<null | StoredRecord<T>> {
|
|
334
|
+
async deleteById(id: string, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
|
|
335
|
+
if (this.model.sync && !options.hardDelete) {
|
|
336
|
+
return this.tombstone({ id } as Where<T>, options);
|
|
337
|
+
}
|
|
276
338
|
const params = new SqlParams();
|
|
277
339
|
const { rows } = await this.run({
|
|
278
340
|
params: params.values,
|
|
279
|
-
text: `DELETE FROM ${this.table} WHERE ${this.where({ id } as Where<T>, params)} RETURNING ${this.selectList()}`,
|
|
341
|
+
text: `DELETE FROM ${this.table} WHERE ${this.where({ id } as Where<T>, params, options)} RETURNING ${this.selectList()}`,
|
|
280
342
|
});
|
|
281
343
|
return this.toRecords(rows)[0] ?? null;
|
|
282
344
|
}
|
|
283
345
|
|
|
284
|
-
async deleteMany(where: Where<T
|
|
346
|
+
async deleteMany(where: Where<T>, options: DeleteOptions = {}): Promise<number> {
|
|
347
|
+
if (this.model.sync && !options.hardDelete) {
|
|
348
|
+
return this.updateRows(where, await this.tombstonePatch(), options);
|
|
349
|
+
}
|
|
285
350
|
const params = new SqlParams();
|
|
286
351
|
const { rowCount } = await this.run({
|
|
287
352
|
params: params.values,
|
|
288
|
-
text: `DELETE FROM ${this.table} WHERE ${this.where(where, params)}`,
|
|
353
|
+
text: `DELETE FROM ${this.table} WHERE ${this.where(where, params, options)}`,
|
|
289
354
|
});
|
|
290
355
|
return rowCount;
|
|
291
356
|
}
|
|
292
357
|
|
|
293
|
-
async deleteOne(where: Where<T
|
|
358
|
+
async deleteOne(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
|
|
359
|
+
if (this.model.sync && !options.hardDelete) {
|
|
360
|
+
return this.tombstone(where, options);
|
|
361
|
+
}
|
|
294
362
|
// Picks and locks one match, and re-checks it at the write — the same guarantee as updateOne()
|
|
295
363
|
const params = new SqlParams();
|
|
296
|
-
const match = this.where(where, params);
|
|
297
|
-
const recheck = this.where(where, params);
|
|
364
|
+
const match = this.where(where, params, options);
|
|
365
|
+
const recheck = this.where(where, params, options);
|
|
298
366
|
const { rows } = await this.run({
|
|
299
367
|
params: params.values,
|
|
300
368
|
text: `DELETE FROM ${this.table} WHERE "id" = (SELECT "id" FROM ${this.table} WHERE ${match} LIMIT 1 FOR UPDATE) AND ${recheck} RETURNING ${this.selectList()}`,
|
|
@@ -302,8 +370,11 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
302
370
|
return this.toRecords(rows)[0] ?? null;
|
|
303
371
|
}
|
|
304
372
|
|
|
305
|
-
async updateMany(where: Where<T>, update: DataStoreUpdate<T
|
|
306
|
-
const patch = await this.preparePatch(update);
|
|
373
|
+
async updateMany(where: Where<T>, update: DataStoreUpdate<T>, options: UpdateOptions = {}): Promise<number> {
|
|
374
|
+
const patch = await this.preparePatch(update, options);
|
|
375
|
+
if (this.model.sync) {
|
|
376
|
+
return this.updateRows(where, patch, options);
|
|
377
|
+
}
|
|
307
378
|
const params = new SqlParams();
|
|
308
379
|
const assignments = this.compileUpdate(patch, params);
|
|
309
380
|
if (!assignments.length) {
|
|
@@ -365,6 +436,165 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
365
436
|
await this.updateColumn(id, column, pullExpression(this.model, field, column, condition, params), params);
|
|
366
437
|
}
|
|
367
438
|
|
|
439
|
+
// -- synchronized models ------------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Insert with the id the caller supplied, or a server id that is generated anew while it is taken (plan §3.3, E6)
|
|
443
|
+
*/
|
|
444
|
+
protected async withServerId<R>(
|
|
445
|
+
doc: Record<string, any>,
|
|
446
|
+
attempt: (doc: Record<string, any>) => Promise<R>,
|
|
447
|
+
): Promise<R> {
|
|
448
|
+
const supplied = suppliedId(doc);
|
|
449
|
+
if (!this.model.sync || (supplied !== undefined && supplied !== null)) {
|
|
450
|
+
return attempt(doc);
|
|
451
|
+
}
|
|
452
|
+
const { _id: _ignored, ...rest } = doc;
|
|
453
|
+
return insertWithServerId(
|
|
454
|
+
this.model.sync.idFormat,
|
|
455
|
+
(id) => attempt({ ...rest, id }),
|
|
456
|
+
(error) => error instanceof DuplicateKeyError && error.primaryKey,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The update that turns a record into a tombstone: `deletedAt` set, every content field cleared (protocol §2) */
|
|
461
|
+
protected async tombstonePatch(): Promise<Record<string, any>> {
|
|
462
|
+
const cleared = tombstoneClearedFields(Object.keys(this.model.fields), this.model.sync);
|
|
463
|
+
return this.preparePatch(
|
|
464
|
+
{
|
|
465
|
+
$set: { [SYNC_DELETED_FIELD]: new Date() },
|
|
466
|
+
...(cleared.length ? { $unset: Object.fromEntries(cleared.map((field) => [field, 1])) } : {}),
|
|
467
|
+
} as unknown as DataStoreUpdate<T>,
|
|
468
|
+
{ tombstone: true },
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Turn the first record matching `where` into a tombstone and return it as it was before */
|
|
473
|
+
protected async tombstone(where: Where<T>, options: DeleteOptions): Promise<null | StoredRecord<T>> {
|
|
474
|
+
const patch = await this.tombstonePatch();
|
|
475
|
+
return this.transact(async (run) => {
|
|
476
|
+
const params = new SqlParams();
|
|
477
|
+
const { rows } = await run({
|
|
478
|
+
params: params.values,
|
|
479
|
+
text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
|
|
480
|
+
});
|
|
481
|
+
if (!rows.length) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
// Mapped before the write: a row outside the tenant scope fails the row check and nothing changes
|
|
485
|
+
const [record] = this.toRecords(rows);
|
|
486
|
+
const write = new SqlParams();
|
|
487
|
+
const assignments = this.compileUpdate(patch, write);
|
|
488
|
+
await run({
|
|
489
|
+
params: write.values,
|
|
490
|
+
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.quote('id')} = ${write.add(rows[0].id)}`,
|
|
491
|
+
});
|
|
492
|
+
await this.numberPending(run, [rows[0].id]);
|
|
493
|
+
return record;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A write to several rows of a synchronized model (plan §3.4, C1d): the rows are locked first, then the transaction
|
|
499
|
+
* takes its change numbers, then its final statements write the rows with their numbers set explicitly — the order
|
|
500
|
+
* is always "rows first, counter last", so the multi-row write cannot deadlock with another store write — on MySQL
|
|
501
|
+
* and MariaDB, whose `FOR UPDATE` also locks index gaps, only because every store write there takes its number last
|
|
502
|
+
* too (`numbersLast()`). The numbers commit with the write. Returns how many rows matched.
|
|
503
|
+
*/
|
|
504
|
+
protected async updateRows(where: Where<T>, patch: Record<string, any>, options: UpdateOptions): Promise<number> {
|
|
505
|
+
// The rows get their numbers in the final statements, not the pending marker
|
|
506
|
+
const { [SYNC_SEQUENCE_FIELD]: _pending, ...set } = patch.$set ?? {};
|
|
507
|
+
const update = { ...patch, $set: set };
|
|
508
|
+
return this.transact(async (run) => {
|
|
509
|
+
const params = new SqlParams();
|
|
510
|
+
const { rows } = await run<{ id: string }>({
|
|
511
|
+
params: params.values,
|
|
512
|
+
text: `SELECT ${this.quote('id')} AS id FROM ${this.table} WHERE ${this.where(where, params, options)} ORDER BY ${this.quote('id')} FOR UPDATE`,
|
|
513
|
+
});
|
|
514
|
+
if (!rows.length) {
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
const ids = rows.map((row) => row.id);
|
|
518
|
+
await this.writeChangeNumbers(run, ids, await this.takeChangeNumbers(run, ids.length), update);
|
|
519
|
+
return ids.length;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Whether a store write takes its change number LAST, in its own transaction, instead of through the trigger: the
|
|
525
|
+
* write marks the row pending (`sync_seq = -1`, which the trigger leaves alone), then the number is taken and set.
|
|
526
|
+
* Needed where a trigger's counter would be held while the write still waits for a lock — MySQL and MariaDB, whose
|
|
527
|
+
* inserts wait for gap locks after the BEFORE INSERT trigger ran. PostgreSQL takes no gap locks: the trigger suffices
|
|
528
|
+
*/
|
|
529
|
+
protected numbersLast(): boolean {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Number the rows a store write marked pending, as the last step of its transaction (`numbersLast()`) */
|
|
534
|
+
protected async numberPending(run: SqlRun, ids: readonly string[]): Promise<void> {
|
|
535
|
+
if (!this.model.sync || !this.numbersLast() || !ids.length) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
await this.writeChangeNumbers(run, ids, await this.takeChangeNumbers(run, ids.length));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Set the rows' change numbers explicitly (the trigger keeps them), with the update's other assignments, in chunks */
|
|
542
|
+
protected async writeChangeNumbers(
|
|
543
|
+
run: SqlRun,
|
|
544
|
+
ids: readonly string[],
|
|
545
|
+
numbers: readonly string[],
|
|
546
|
+
update: Record<string, any> = {},
|
|
547
|
+
): Promise<void> {
|
|
548
|
+
for (let start = 0; start < ids.length; start += SYNC_CHUNK) {
|
|
549
|
+
const chunk = ids.slice(start, start + SYNC_CHUNK);
|
|
550
|
+
const write = new SqlParams();
|
|
551
|
+
const cases = chunk
|
|
552
|
+
.map((id, index) => `WHEN ${write.add(id)} THEN ${this.bigintParam(write, numbers[start + index])}`)
|
|
553
|
+
.join(' ');
|
|
554
|
+
const assignments = [
|
|
555
|
+
...(Object.keys(update).length ? this.compileUpdate(update, write) : []),
|
|
556
|
+
`${this.quote('sync_seq')} = CASE ${this.quote('id')} ${cases} END`,
|
|
557
|
+
];
|
|
558
|
+
const list = chunk.map((id) => write.add(id)).join(', ');
|
|
559
|
+
await run({
|
|
560
|
+
params: write.values,
|
|
561
|
+
text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.quote('id')} IN (${list})`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* `count` consecutive change numbers for this table, in commit order: the model's advisory lock, held until the
|
|
568
|
+
* transaction ends, then the sequence — the trigger's own order (`syncFunctionStatements()`)
|
|
569
|
+
*/
|
|
570
|
+
protected async takeChangeNumbers(run: SqlRun, count: number): Promise<string[]> {
|
|
571
|
+
const table = tableName(this.model);
|
|
572
|
+
await run({
|
|
573
|
+
params: [table],
|
|
574
|
+
text: `SELECT pg_advisory_xact_lock(hashtext('lenne.tech:sync:' || current_schema() || '.' || $1))`,
|
|
575
|
+
});
|
|
576
|
+
const { rows } = await run<{ n: string }>({
|
|
577
|
+
params: [syncSequenceName(table), count],
|
|
578
|
+
text: `SELECT nextval(format('%I.%I', current_schema(), $1::text)::regclass)::text AS n FROM generate_series(1, $2::int)`,
|
|
579
|
+
});
|
|
580
|
+
return rows.map((row) => row.n).sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** A bound bigint value as this database reads it */
|
|
584
|
+
protected bigintParam(params: SqlParams, value: string): string {
|
|
585
|
+
return `${params.add(value)}::bigint`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Several statements in one transaction with the request's tenant scope; errors mapped like `run()` */
|
|
589
|
+
protected async transact<R>(fn: (run: SqlRun) => Promise<R>): Promise<R> {
|
|
590
|
+
const scope = resolveTenantScope();
|
|
591
|
+
try {
|
|
592
|
+
return await this.executor.transaction((tx) => fn((statement) => tx.run(statement, scope)));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw this.mapError(error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
368
598
|
// -- native access --------------------------------------------------------------------------------------
|
|
369
599
|
|
|
370
600
|
native<N = unknown>(reason: string): N {
|
|
@@ -382,7 +612,9 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
382
612
|
// -- before-layer ---------------------------------------------------------------------------------------
|
|
383
613
|
|
|
384
614
|
/** The policies the Mongoose plugins apply to `save()`, in their registration order */
|
|
385
|
-
protected async beforeInsert(
|
|
615
|
+
protected async beforeInsert(input: Record<string, any>): Promise<Record<string, any>> {
|
|
616
|
+
// A synchronized model's system fields are the store's, whatever the caller sent
|
|
617
|
+
const doc = this.model.sync ? syncInsertRecord(input) : input;
|
|
386
618
|
if (this.model.tenantScoped) {
|
|
387
619
|
const tenantId = resolveSingleTenantId(this.modelName);
|
|
388
620
|
if (tenantId) {
|
|
@@ -415,11 +647,23 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
415
647
|
if (updatedAt) {
|
|
416
648
|
doc[updatedAt] = now;
|
|
417
649
|
}
|
|
650
|
+
if (this.model.sync && this.numbersLast()) {
|
|
651
|
+
doc[SYNC_SEQUENCE_FIELD] = SYNC_SEQUENCE_PENDING;
|
|
652
|
+
}
|
|
418
653
|
return doc;
|
|
419
654
|
}
|
|
420
655
|
|
|
421
656
|
/** The policies the Mongoose plugins apply to `findOneAndUpdate()` */
|
|
422
|
-
protected async beforeUpdate(update: Record<string, any>, options:
|
|
657
|
+
protected async beforeUpdate(update: Record<string, any>, options: WriteOptions = {}): Promise<Record<string, any>> {
|
|
658
|
+
if (this.model.sync) {
|
|
659
|
+
// Only the store writes a synchronized model's system fields: every write increments the version, and only the
|
|
660
|
+
// tombstone write sets `deletedAt` (plan §3.3, E1). The change position is the trigger's
|
|
661
|
+
stripSyncFields(update, options.tombstone === true);
|
|
662
|
+
update.$inc = { ...update.$inc, [SYNC_VERSION_FIELD]: 1 };
|
|
663
|
+
if (this.numbersLast()) {
|
|
664
|
+
update.$set = { ...update.$set, [SYNC_SEQUENCE_FIELD]: SYNC_SEQUENCE_PENDING };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
423
667
|
if (this.model.tenantScoped) {
|
|
424
668
|
const single = resolveSingleTenantId(this.modelName);
|
|
425
669
|
if (single) {
|
|
@@ -451,7 +695,7 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
451
695
|
}
|
|
452
696
|
|
|
453
697
|
/** The update as the store writes it: the caller's object untouched, the policies applied, undeclared paths dropped */
|
|
454
|
-
protected async preparePatch(update: DataStoreUpdate<T>, options:
|
|
698
|
+
protected async preparePatch(update: DataStoreUpdate<T>, options: WriteOptions = {}): Promise<Record<string, any>> {
|
|
455
699
|
return this.dropUndeclared(await this.beforeUpdate(clone(update), options));
|
|
456
700
|
}
|
|
457
701
|
|
|
@@ -484,12 +728,24 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
484
728
|
|
|
485
729
|
// -- statements -----------------------------------------------------------------------------------------
|
|
486
730
|
|
|
487
|
-
/**
|
|
488
|
-
|
|
731
|
+
/**
|
|
732
|
+
* The caller's filter AND the tenant filter AND — on a synchronized model, unless `includeTombstones` — the live-record
|
|
733
|
+
* condition; throws the safety net's 403 before any statement runs
|
|
734
|
+
*/
|
|
735
|
+
protected where(
|
|
736
|
+
where: undefined | Where<T>,
|
|
737
|
+
params: SqlParams,
|
|
738
|
+
options: { includeTombstones?: boolean } = {},
|
|
739
|
+
): string {
|
|
489
740
|
const tenantFilter = this.model.tenantScoped ? resolveTenantFilter(this.modelName) : undefined;
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
741
|
+
const conditions: Where[] = [where ?? {}];
|
|
742
|
+
if (tenantFilter && Object.keys(tenantFilter).length) {
|
|
743
|
+
conditions.push(tenantFilter);
|
|
744
|
+
}
|
|
745
|
+
if (this.model.sync && !options.includeTombstones) {
|
|
746
|
+
conditions.push(liveFilter());
|
|
747
|
+
}
|
|
748
|
+
return this.compileWhere((conditions.length > 1 ? { $and: conditions } : conditions[0]) as Where, params);
|
|
493
749
|
}
|
|
494
750
|
|
|
495
751
|
/** The condition of a neutral filter in this database's SQL */
|
|
@@ -497,11 +753,11 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
497
753
|
return compilePostgresWhere(this.model, where, params);
|
|
498
754
|
}
|
|
499
755
|
|
|
500
|
-
protected async exists(where: undefined | Where<T
|
|
756
|
+
protected async exists(where: undefined | Where<T>, options: { includeTombstones?: boolean } = {}): Promise<boolean> {
|
|
501
757
|
const params = new SqlParams();
|
|
502
758
|
const { rows } = await this.run<{ found: boolean }>({
|
|
503
759
|
params: params.values,
|
|
504
|
-
text: `SELECT EXISTS (SELECT 1 FROM ${this.table} WHERE ${this.where(where, params)}) AS found`,
|
|
760
|
+
text: `SELECT EXISTS (SELECT 1 FROM ${this.table} WHERE ${this.where(where, params, options)}) AS found`,
|
|
505
761
|
});
|
|
506
762
|
return rows[0].found;
|
|
507
763
|
}
|
|
@@ -630,7 +886,8 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
630
886
|
return undefined;
|
|
631
887
|
}
|
|
632
888
|
if (!field.isArray && field.kind === 'bigint') {
|
|
633
|
-
|
|
889
|
+
// The change position travels as a number: JSON has no bigint, and the sequence stays far below 2^53
|
|
890
|
+
return field.name === SYNC_SEQUENCE_FIELD ? Number(value) : BigInt(value as string);
|
|
634
891
|
}
|
|
635
892
|
return value;
|
|
636
893
|
}
|
|
@@ -726,7 +983,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
|
|
|
726
983
|
const code = (error as { code?: unknown })?.code;
|
|
727
984
|
switch (code) {
|
|
728
985
|
case '23505':
|
|
729
|
-
return new DuplicateKeyError(
|
|
986
|
+
return new DuplicateKeyError(
|
|
987
|
+
this.modelName,
|
|
988
|
+
String((error as { constraint?: unknown }).constraint ?? '').endsWith('_pkey'),
|
|
989
|
+
);
|
|
730
990
|
case '23514':
|
|
731
991
|
// The roles CHECK is the only check constraint the framework creates
|
|
732
992
|
return new BadRequestException(`${this.modelName}: the value is not allowed`);
|
|
@@ -757,6 +1017,27 @@ function clone<V>(value: V): V {
|
|
|
757
1017
|
return value;
|
|
758
1018
|
}
|
|
759
1019
|
|
|
1020
|
+
/** Remove a synchronized model's system fields from a caller's update; the tombstone write keeps `deletedAt` */
|
|
1021
|
+
function stripSyncFields(update: Record<string, any>, tombstone: boolean): void {
|
|
1022
|
+
const system = (path: string) => {
|
|
1023
|
+
const head = path.split('.', 1)[0];
|
|
1024
|
+
return SYNC_SYSTEM_FIELDS.includes(head) && !(tombstone && head === SYNC_DELETED_FIELD);
|
|
1025
|
+
};
|
|
1026
|
+
for (const [key, value] of Object.entries(update)) {
|
|
1027
|
+
if (!key.startsWith('$')) {
|
|
1028
|
+
if (system(key)) {
|
|
1029
|
+
delete update[key];
|
|
1030
|
+
}
|
|
1031
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1032
|
+
for (const path of Object.keys(value)) {
|
|
1033
|
+
if (system(path)) {
|
|
1034
|
+
delete value[path];
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
760
1041
|
function hasOperator(update: Record<string, any>, field: string): boolean {
|
|
761
1042
|
return Object.values(update).some((part) => part && typeof part === 'object' && field in part) || field in update;
|
|
762
1043
|
}
|
|
@@ -8,14 +8,18 @@ import {
|
|
|
8
8
|
assertSafeMysqlApplicationAccount,
|
|
9
9
|
type MysqlSchemaOptions,
|
|
10
10
|
mysqlAccount,
|
|
11
|
+
anyTableMissing,
|
|
11
12
|
verifyMysqlSchema,
|
|
13
|
+
withFrameworkDefaultCollation,
|
|
14
|
+
withMysqlSchemaLock,
|
|
12
15
|
} from './mysql/mysql-schema.migrator.js';
|
|
13
16
|
import type { MysqlVariant } from './mysql/mysql-session.js';
|
|
14
17
|
import { PostgresLockStore } from './postgres/postgres-lock-store.js';
|
|
15
|
-
import type
|
|
18
|
+
import { grantStatement, type PostgresSchemaOptions } from './postgres/postgres-schema.compiler.js';
|
|
16
19
|
import {
|
|
17
20
|
applyPostgresSchema,
|
|
18
21
|
assertSafeApplicationRole,
|
|
22
|
+
SCHEMA_LOCK_SQL,
|
|
19
23
|
verifyPostgresSchema,
|
|
20
24
|
} from './postgres/postgres-schema.migrator.js';
|
|
21
25
|
import { SqlDataStore, type SqlDataStoreOptions } from './sql-data-store.js';
|
|
@@ -36,12 +40,27 @@ export interface SqlDialect {
|
|
|
36
40
|
assertSafeApplication(app: SqlExecutor, tables: string[]): Promise<void>;
|
|
37
41
|
createLockStore(app: SqlExecutor): LockStore;
|
|
38
42
|
createStore(app: SqlExecutor, model: ModelDescriptor, options: SqlDataStoreOptions): SqlDataStore<any>;
|
|
43
|
+
/**
|
|
44
|
+
* What a library with its own database adapter runs on (Better-Auth's Kysely adapter): the `pg` pool on PostgreSQL; on
|
|
45
|
+
* MySQL / MariaDB the `mysql2` pool's promise wrapper — Better-Auth recognises a MySQL pool by `getConnection`, and
|
|
46
|
+
* Kysely's `MysqlDialect` reads the callback pool it needs from the wrapper's `pool` field. The same connections, so
|
|
47
|
+
* the library's statements run in the session the framework set up.
|
|
48
|
+
*/
|
|
49
|
+
foreignPool(executor: SqlExecutor): unknown;
|
|
39
50
|
/** How a tenant-isolated model's rows are protected, for the boot log */
|
|
40
51
|
isolation(isolated: boolean): string;
|
|
41
52
|
/** Without an owner connection: only check, and refuse to start on any pending change */
|
|
42
53
|
verifySchema(app: SqlExecutor, models: ModelDescriptor[]): Promise<SchemaChange[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Run `migrate` — the schema run of a library that owns tables of its own (Better-Auth), as the owner — while `lock`
|
|
56
|
+
* holds the framework's schema lock, and leave the application role the rights on `tables` the framework's tables
|
|
57
|
+
* give it
|
|
58
|
+
*/
|
|
59
|
+
withForeignSchemaLock(lock: SqlExecutor, tables: string[], migrate: () => Promise<void>): Promise<void>;
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
const SYSTEM = { kind: 'system' } as const;
|
|
63
|
+
|
|
45
64
|
export interface SqlDialectContext {
|
|
46
65
|
/** The application role as the connection URL names it */
|
|
47
66
|
appRole: string;
|
|
@@ -60,9 +79,18 @@ function postgresDialect(context: SqlDialectContext): SqlDialect {
|
|
|
60
79
|
assertSafeApplication: (app, tables) => assertSafeApplicationRole(app, tables),
|
|
61
80
|
createLockStore: (app) => new PostgresLockStore(app),
|
|
62
81
|
createStore: (app, model, storeOptions) => new SqlDataStore<any>(app, model, storeOptions),
|
|
82
|
+
foreignPool: (executor) => executor.driverPool,
|
|
63
83
|
isolation: (isolated) => (isolated ? ' (row-level security)' : ''),
|
|
64
84
|
label: 'PostgreSQL',
|
|
65
85
|
verifySchema: (app, models) => verifyPostgresSchema(app, models, options),
|
|
86
|
+
withForeignSchemaLock: (lock, tables, migrate) =>
|
|
87
|
+
lock.transaction(async (tx) => {
|
|
88
|
+
await tx.run({ params: [], text: SCHEMA_LOCK_SQL }, SYSTEM);
|
|
89
|
+
await migrate();
|
|
90
|
+
for (const table of tables) {
|
|
91
|
+
await tx.run({ params: [], text: grantStatement(table, context.appRole) }, SYSTEM);
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
66
94
|
};
|
|
67
95
|
}
|
|
68
96
|
|
|
@@ -78,8 +106,17 @@ function mysqlDialect(variant: MysqlVariant): SqlDialect {
|
|
|
78
106
|
assertSafeApplication: (app) => assertSafeMysqlApplicationAccount(app, variant),
|
|
79
107
|
createLockStore: (app) => new MysqlLockStore(app),
|
|
80
108
|
createStore: (app, model, storeOptions) => new MysqlDataStore<any>(app as MysqlExecutor, model, storeOptions),
|
|
109
|
+
foreignPool: (executor) => (executor.driverPool as { promise(): unknown }).promise(),
|
|
81
110
|
isolation: (isolated) => (isolated ? ' (tenant isolation in the application only)' : ''),
|
|
82
111
|
label: variant === 'mysql' ? 'MySQL' : 'MariaDB',
|
|
83
112
|
verifySchema: async (app, models) => verifyMysqlSchema(app, models, await options(app)),
|
|
113
|
+
// The application account's rights are granted for the whole database (`SELECT, INSERT, UPDATE, DELETE ON db.*`),
|
|
114
|
+
// which the framework's own schema run established before: the tables the library creates are covered by it. A
|
|
115
|
+
// table it creates gets the framework's collation, which the library cannot be told — switched only on a run that
|
|
116
|
+
// creates one, because the switch locks every table of the database
|
|
117
|
+
withForeignSchemaLock: (lock, tables, migrate) =>
|
|
118
|
+
withMysqlSchemaLock(lock, async (run) =>
|
|
119
|
+
(await anyTableMissing(run, tables)) ? withFrameworkDefaultCollation(run, migrate) : migrate(),
|
|
120
|
+
),
|
|
84
121
|
};
|
|
85
122
|
}
|