@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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry.js';
|
|
4
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
5
|
+
import { CoreSyncService } from './core-sync.service.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the sync routes share: the tenant scope of a header-less request and the per-user rate limit. Declares no route,
|
|
9
|
+
* so `CoreSyncController` (pull, push) and `CoreSyncHintsController` (hints) each register only their own.
|
|
10
|
+
*
|
|
11
|
+
* `@Injectable()` only so the constructor's parameter types are emitted: the two controllers inherit it.
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export abstract class CoreSyncBaseController {
|
|
15
|
+
constructor(protected readonly syncService: CoreSyncService) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Without `X-Tenant-Id` a sync request spans every tenant the caller is an active member of (plan §3.4) — or every
|
|
19
|
+
* tenant for an administrator under `adminBypass`. The tenant guard admits an `S_USER` route without resolving
|
|
20
|
+
* those memberships, so this asks the guard's own membership logic (`core-tenant-context.registry.ts`). A header
|
|
21
|
+
* the guard validated is kept; without multi-tenancy nothing happens.
|
|
22
|
+
*/
|
|
23
|
+
protected async scopeTenants(req: any, currentUser: any): Promise<void> {
|
|
24
|
+
if (!req || !currentUser?.id || req.tenantId || req.tenantIds || req.isAdminBypass) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const resolved = await getTenantContextResolver()?.resolve(currentUser, undefined);
|
|
28
|
+
if (resolved?.isAdminBypass) {
|
|
29
|
+
req.isAdminBypass = true;
|
|
30
|
+
} else if (resolved?.tenantIds) {
|
|
31
|
+
req.tenantIds = resolved.tenantIds;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 429 with `Retry-After` once the caller exceeded the limit of this kind */
|
|
36
|
+
protected async limit(kind: 'pull' | 'push', currentUser: any, res: any): Promise<void> {
|
|
37
|
+
const retryAfter = await this.syncService.rateLimit(kind, currentUser);
|
|
38
|
+
if (retryAfter !== undefined) {
|
|
39
|
+
res?.setHeader?.('Retry-After', String(retryAfter));
|
|
40
|
+
throw new HttpException(ErrorCode.RATE_LIMIT_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pull cursor (protocol §5, plan §3.4): opaque to the client, signed by the server, bound to the model and the user
|
|
5
|
+
* it was issued for. Format: `base64url(JSON).base64url(HMAC-SHA256)`. Nothing in it is a secret — the signature only
|
|
6
|
+
* keeps a client from editing it into another user's, another model's or an earlier position.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Where a pull continues */
|
|
10
|
+
export type SyncCursorPosition =
|
|
11
|
+
/** SQL: the last change number delivered (`syncSeq`) */
|
|
12
|
+
| { seq: number }
|
|
13
|
+
/** MongoDB: the keyset `(syncAt, id)` of the last record delivered; `id: ''` starts at `at` itself */
|
|
14
|
+
| { at: number; id: string };
|
|
15
|
+
|
|
16
|
+
export interface SyncCursorState {
|
|
17
|
+
/** Hash of the access epochs the cursor was issued under */
|
|
18
|
+
epochs: string;
|
|
19
|
+
/** Hash of the caller's access when the cursor was issued (user, tenant scope, roles, verification) */
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
model: string;
|
|
22
|
+
position: SyncCursorPosition;
|
|
23
|
+
/** Start (epoch ms) of the run in progress; absent once a page ended its run */
|
|
24
|
+
runStart?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Start (epoch ms) of the run since which the client's state is complete — compared with the tombstone horizon: a
|
|
27
|
+
* tombstone purged after this point may never have reached the client
|
|
28
|
+
*/
|
|
29
|
+
since: number;
|
|
30
|
+
user: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Cursors longer than this are refused unread: nothing the server issues comes close */
|
|
34
|
+
const MAX_CURSOR_LENGTH = 2048;
|
|
35
|
+
|
|
36
|
+
/** The key the cursors are signed with, derived from the configured secret (never the secret itself) */
|
|
37
|
+
export function deriveCursorKey(secret: string): Buffer {
|
|
38
|
+
return Buffer.from(hkdfSync('sha256', secret, 'lenne.tech', 'nest-server:sync-cursor:v1', 32));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encodeSyncCursor(state: SyncCursorState, key: Buffer): string {
|
|
42
|
+
const payload = Buffer.from(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
e: state.epochs,
|
|
45
|
+
f: state.fingerprint,
|
|
46
|
+
k: state.since,
|
|
47
|
+
m: state.model,
|
|
48
|
+
p: 'seq' in state.position ? { s: state.position.seq } : { a: state.position.at, i: state.position.id },
|
|
49
|
+
...(state.runStart === undefined ? {} : { r: state.runStart }),
|
|
50
|
+
u: state.user,
|
|
51
|
+
v: 1,
|
|
52
|
+
}),
|
|
53
|
+
).toString('base64url');
|
|
54
|
+
return `${payload}.${sign(payload, key)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The cursor's state, or `undefined` when it is malformed, tampered with or of another format version */
|
|
58
|
+
export function decodeSyncCursor(cursor: unknown, key: Buffer): SyncCursorState | undefined {
|
|
59
|
+
if (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR_LENGTH) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const [payload, signature, rest] = cursor.split('.');
|
|
63
|
+
if (!payload || !signature || rest !== undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const expected = Buffer.from(sign(payload, key));
|
|
67
|
+
const given = Buffer.from(signature);
|
|
68
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
let raw: any;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
!raw ||
|
|
79
|
+
raw.v !== 1 ||
|
|
80
|
+
typeof raw.m !== 'string' ||
|
|
81
|
+
typeof raw.u !== 'string' ||
|
|
82
|
+
typeof raw.f !== 'string' ||
|
|
83
|
+
typeof raw.e !== 'string' ||
|
|
84
|
+
!Number.isFinite(raw.k) ||
|
|
85
|
+
(raw.r !== undefined && !Number.isFinite(raw.r)) ||
|
|
86
|
+
!raw.p ||
|
|
87
|
+
typeof raw.p !== 'object'
|
|
88
|
+
) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
let position: SyncCursorPosition;
|
|
92
|
+
if (Number.isFinite(raw.p.s)) {
|
|
93
|
+
position = { seq: raw.p.s };
|
|
94
|
+
} else if (Number.isFinite(raw.p.a) && typeof raw.p.i === 'string') {
|
|
95
|
+
position = { at: raw.p.a, id: raw.p.i };
|
|
96
|
+
} else {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
epochs: raw.e,
|
|
101
|
+
fingerprint: raw.f,
|
|
102
|
+
model: raw.m,
|
|
103
|
+
position,
|
|
104
|
+
...(raw.r === undefined ? {} : { runStart: raw.r }),
|
|
105
|
+
since: raw.k,
|
|
106
|
+
user: raw.u,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A short, stable hash of a JSON-serializable value (fingerprints, epoch sets) */
|
|
111
|
+
export function stableHash(value: unknown): string {
|
|
112
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('base64url').slice(0, 32);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sign(payload: string, key: Buffer): string {
|
|
116
|
+
return createHmac('sha256', key).update(payload).digest('base64url');
|
|
117
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller, Get, NotFoundException, Query, Req, Res } from '@nestjs/common';
|
|
2
|
+
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
3
|
+
|
|
4
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator.js';
|
|
5
|
+
import { Roles } from '../../common/decorators/roles.decorator.js';
|
|
6
|
+
import { RoleEnum } from '../../common/enums/role.enum.js';
|
|
7
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
8
|
+
import { CoreSyncBaseController } from './core-sync-base.controller.js';
|
|
9
|
+
import { SYNC_DEFAULTS } from './core-sync.constants.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `GET /sync/hints` (protocol §9): server-sent change hints. A controller of its own, registered before
|
|
13
|
+
* `CoreSyncController`: Nest registers a class's own routes before the ones it inherits, so a project controller that
|
|
14
|
+
* re-declares `pull()` (`@Get(':model')`) would otherwise read `hints` as a model name and answer 404 — and an
|
|
15
|
+
* EventSource does not reconnect after an error status, so the hints would stop without a sign.
|
|
16
|
+
*
|
|
17
|
+
* A signed-in caller always gets the stream (200); models the caller may not pull never send a hint, so the stream
|
|
18
|
+
* reveals nothing about which models exist. API tokens are refused here like on every route without
|
|
19
|
+
* `@ApiTokenScopes()`; a project that wants them extends this controller, re-declares `hints()` with
|
|
20
|
+
* `@ApiTokenScopes('sync')` and passes it as `sync.hintsController` override.
|
|
21
|
+
*/
|
|
22
|
+
@ApiTags('sync')
|
|
23
|
+
@Controller(SYNC_DEFAULTS.path)
|
|
24
|
+
@Roles(RoleEnum.S_USER)
|
|
25
|
+
export class CoreSyncHintsController extends CoreSyncBaseController {
|
|
26
|
+
/** The stream is `CoreSyncService.openHintStream()`; 404 while the SSE channel is switched off */
|
|
27
|
+
@ApiBearerAuth()
|
|
28
|
+
@ApiOperation({
|
|
29
|
+
description:
|
|
30
|
+
'Server-sent events: `event: change` with `{ model, at }` whenever a model the caller may pull changes; ' +
|
|
31
|
+
'`models` (comma-separated) narrows the stream',
|
|
32
|
+
summary: 'Change hints (SSE)',
|
|
33
|
+
})
|
|
34
|
+
@Get('hints')
|
|
35
|
+
@Roles(RoleEnum.S_USER)
|
|
36
|
+
async hints(
|
|
37
|
+
@Query('models') models: string,
|
|
38
|
+
@CurrentUser() currentUser: any,
|
|
39
|
+
@Req() req: any,
|
|
40
|
+
@Res() res: any,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if (!this.syncService.hintsEnabled('sse')) {
|
|
43
|
+
throw new NotFoundException(ErrorCode.SYNC_MODEL_NOT_FOUND);
|
|
44
|
+
}
|
|
45
|
+
// Counted like a pull: an EventSource reconnects on its own, and every reconnect is a request
|
|
46
|
+
await this.limit('pull', currentUser, res);
|
|
47
|
+
// Without X-Tenant-Id the stream spans every membership of the caller, as a pull does
|
|
48
|
+
await this.scopeTenants(req, currentUser);
|
|
49
|
+
const named = typeof models === 'string' ? models.split(',').map((model) => model.trim()) : [];
|
|
50
|
+
await this.syncService.openHintStream(req, res, currentUser, named.filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import type { ISync } from '../../common/interfaces/server-options.interface.js';
|
|
5
|
+
import { ConfigService } from '../../common/services/config.service.js';
|
|
6
|
+
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
7
|
+
import { SYNC_DEFAULTS } from './core-sync.constants.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A hint as the bus carries it: a synchronized model changed, in a tenant (`undefined` on a model without tenants).
|
|
11
|
+
* Subscribers receive `{ model, at }` only (plan §3.9); the tenant decides who receives it.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncHintEvent {
|
|
14
|
+
at: number;
|
|
15
|
+
model: string;
|
|
16
|
+
tenantId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The hint bus of offline sync (plan §3.9, protocol §9): a change of a synchronized model becomes one hint per model and
|
|
21
|
+
* tenant per `sync.hints.debounceMs`, delivered to this replica's subscribers at once and, with `redis`, to every other
|
|
22
|
+
* replica's through one Redis channel. Without Redis, or while it does not answer, hints stay on this replica — a hint
|
|
23
|
+
* only means "pull now", so a client that misses one learns of the change at its next pull.
|
|
24
|
+
*
|
|
25
|
+
* Who may receive a hint is not decided here: each subscriber filters by its own access (`CoreSyncService`).
|
|
26
|
+
*/
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class CoreSyncHintService implements OnApplicationBootstrap, OnModuleDestroy {
|
|
29
|
+
protected readonly logger = new Logger(CoreSyncHintService.name);
|
|
30
|
+
/** This replica, so a hint it published is not delivered a second time when Redis brings it back */
|
|
31
|
+
protected readonly origin = randomUUID();
|
|
32
|
+
protected readonly subscribers = new Set<(hint: SyncHintEvent) => void>();
|
|
33
|
+
/** Pending hints by model and tenant: one per key and debounce window, removed when sent */
|
|
34
|
+
protected readonly pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
35
|
+
protected channel?: string;
|
|
36
|
+
protected redisListener?: (channel: string, message: string) => void;
|
|
37
|
+
|
|
38
|
+
constructor(@Optional() protected readonly redisService?: CoreRedisService) {}
|
|
39
|
+
|
|
40
|
+
/** The `sync.hints` configuration (an object; `true` / absent read as `{}`, `false` as `{ enabled: false }`) */
|
|
41
|
+
protected get config(): Exclude<ISync['hints'], boolean | undefined> {
|
|
42
|
+
const sync = ConfigService.configFastButReadOnly?.sync;
|
|
43
|
+
const hints = sync && typeof sync === 'object' ? sync.hints : undefined;
|
|
44
|
+
if (hints === false) {
|
|
45
|
+
return { enabled: false };
|
|
46
|
+
}
|
|
47
|
+
return hints && typeof hints === 'object' ? hints : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether hints are sent at all (`sync.hints`, on by default) */
|
|
51
|
+
get enabled(): boolean {
|
|
52
|
+
return this.config.enabled !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `sync.hints.debounceMs`: at most one hint per model and tenant per window; an invalid value → the default */
|
|
56
|
+
protected get debounceMs(): number {
|
|
57
|
+
const value = this.config.debounceMs;
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : SYNC_DEFAULTS.hintDebounceMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
62
|
+
if (!this.enabled || !this.redisService?.enabled) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const channel = this.redisService.key('sync', 'hints');
|
|
66
|
+
try {
|
|
67
|
+
// Held in a field: the subscriber connection is shared, a listener left behind would deliver into a destroyed bus
|
|
68
|
+
this.channel = channel;
|
|
69
|
+
this.redisListener = (incoming: string, message: string) => {
|
|
70
|
+
if (incoming === channel) {
|
|
71
|
+
this.receive(message);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
this.redisService.getSubscriber().on('message', this.redisListener);
|
|
75
|
+
// Registered with the Redis service, so the subscription comes back after every reconnect
|
|
76
|
+
await this.redisService.subscribeChannel(channel);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.logger.warn(
|
|
79
|
+
`Sync hints: Redis subscribe failed (${(error as Error)?.message ?? error}) — retried when the connection is ` +
|
|
80
|
+
"up; until then this replica's subscribers receive only its own hints",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onModuleDestroy(): void {
|
|
86
|
+
for (const timer of this.pending.values()) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
this.pending.clear();
|
|
90
|
+
this.subscribers.clear();
|
|
91
|
+
if (this.redisListener) {
|
|
92
|
+
try {
|
|
93
|
+
this.redisService?.getSubscriber().off('message', this.redisListener);
|
|
94
|
+
if (this.channel) {
|
|
95
|
+
void this.redisService?.releaseChannel(this.channel).catch(() => undefined);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Redis already gone — nothing to detach from
|
|
99
|
+
}
|
|
100
|
+
this.redisListener = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A synchronized model changed in these tenants (none: a model without tenants). Sent as one hint per model and
|
|
106
|
+
* tenant once the debounce window ends — a burst of writes is one hint.
|
|
107
|
+
*/
|
|
108
|
+
notify(model: string, tenantIds: readonly (string | undefined)[] = [undefined]): void {
|
|
109
|
+
if (!this.enabled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const tenantId of tenantIds.length ? new Set(tenantIds) : [undefined]) {
|
|
113
|
+
const key = `${model}\u0000${tenantId ?? ''}`;
|
|
114
|
+
if (this.pending.has(key)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
this.pending.delete(key);
|
|
119
|
+
this.send({ at: Date.now(), model, ...(tenantId ? { tenantId } : {}) });
|
|
120
|
+
}, this.debounceMs);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
this.pending.set(key, timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Receive every hint (this replica's and, with Redis, every other's); answers the unsubscribe */
|
|
127
|
+
subscribe(handler: (hint: SyncHintEvent) => void): () => void {
|
|
128
|
+
this.subscribers.add(handler);
|
|
129
|
+
return () => {
|
|
130
|
+
this.subscribers.delete(handler);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Deliver a hint here and hand it to the other replicas */
|
|
135
|
+
protected send(hint: SyncHintEvent): void {
|
|
136
|
+
this.deliver(hint);
|
|
137
|
+
if (!this.channel || !this.redisService?.enabled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
void this.redisService
|
|
142
|
+
.getClient()
|
|
143
|
+
.publish(this.channel, JSON.stringify({ ...hint, origin: this.origin }))
|
|
144
|
+
.catch((error: Error) => this.logger.debug(`Sync hint publish failed: ${error.message}`));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.logger.debug(`Sync hint publish failed: ${(error as Error)?.message ?? error}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A hint from the Redis channel — another replica's; this replica's own were delivered when sent */
|
|
151
|
+
protected receive(message: string): void {
|
|
152
|
+
let parsed: any;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(message);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!parsed || parsed.origin === this.origin || typeof parsed.model !== 'string' || !Number.isFinite(parsed.at)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.deliver({
|
|
162
|
+
at: Number(parsed.at),
|
|
163
|
+
model: parsed.model,
|
|
164
|
+
...(typeof parsed.tenantId === 'string' && parsed.tenantId ? { tenantId: parsed.tenantId } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected deliver(hint: SyncHintEvent): void {
|
|
169
|
+
// A Set tolerates a subscriber removing itself while it is called
|
|
170
|
+
for (const handler of this.subscribers) {
|
|
171
|
+
try {
|
|
172
|
+
handler(hint);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.logger.warn(`Sync hint subscriber failed: ${(error as Error)?.message ?? error}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createSchema } from '../../common/persistence/mongo/mongoose-compat.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sync module's infrastructure stores (offline-sync plan §3.3 E7, §3.7) as framework models: registered with
|
|
5
|
+
* `CorePersistenceModule.forFeature()`, they follow `database` like every other model and run through one code path
|
|
6
|
+
* on every database — a collection on MongoDB, a table on SQL. Nothing here relies on the TTL for correctness: every
|
|
7
|
+
* reader checks `expiresAt` itself; MongoDB's TTL index and the SQL expiry sweep only reclaim the space.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One pushed mutation per `key` (model, user, record, mutation id — hashed): claimed before the record is written
|
|
12
|
+
* (`state: 'pending'`), then its outcome (`state: 'done'` with `status`, `version`, `code`). `attempt` makes every
|
|
13
|
+
* takeover a conditional write that exactly one request wins.
|
|
14
|
+
*/
|
|
15
|
+
export const SyncMutationSchema = createSchema(
|
|
16
|
+
{
|
|
17
|
+
attempt: { type: Number },
|
|
18
|
+
claimedAt: { type: Date },
|
|
19
|
+
code: { type: String },
|
|
20
|
+
expiresAt: { index: { expireAfterSeconds: 0 }, type: Date },
|
|
21
|
+
key: { index: { unique: true }, type: String },
|
|
22
|
+
state: { type: String },
|
|
23
|
+
status: { type: String },
|
|
24
|
+
version: { type: Number },
|
|
25
|
+
},
|
|
26
|
+
{ collection: 'syncMutations', versionKey: false },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Access epochs (`epoch:…` keys, an integer bumped when access to synchronized records changes, plan §3.8) and tombstone
|
|
31
|
+
* horizons (`horizon:<model>`, the oldest point a cursor may continue from, advanced by the purge before it deletes).
|
|
32
|
+
*/
|
|
33
|
+
export const SyncStateSchema = createSchema(
|
|
34
|
+
{
|
|
35
|
+
epoch: { type: Number },
|
|
36
|
+
horizon: { type: Date },
|
|
37
|
+
key: { index: { unique: true }, type: String },
|
|
38
|
+
},
|
|
39
|
+
{ collection: 'syncStates', versionKey: false },
|
|
40
|
+
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { DataStore, StoredRecord } from '../../common/persistence/data-store.interface.js';
|
|
4
|
+
|
|
5
|
+
/** The recorded outcome of a mutation: what a repeat of it is answered with (protocol §6.5) */
|
|
6
|
+
export interface SyncMutationOutcome {
|
|
7
|
+
code?: string;
|
|
8
|
+
status: string;
|
|
9
|
+
version?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a claim found:
|
|
14
|
+
* - `claimed`: this request writes the mutation and then records its outcome (`complete()`) or gives it up (`release()`)
|
|
15
|
+
* - `busy`: another request is writing it right now — answer `retry`
|
|
16
|
+
* - `done`: it was applied before — answer with its outcome
|
|
17
|
+
*/
|
|
18
|
+
export type SyncMutationClaim = { kind: 'busy' } | { kind: 'claimed' } | { kind: 'done'; outcome: SyncMutationOutcome };
|
|
19
|
+
|
|
20
|
+
interface MutationRecord {
|
|
21
|
+
attempt?: number;
|
|
22
|
+
claimedAt?: Date;
|
|
23
|
+
code?: null | string;
|
|
24
|
+
expiresAt?: Date;
|
|
25
|
+
key: string;
|
|
26
|
+
state?: string;
|
|
27
|
+
status?: null | string;
|
|
28
|
+
version?: null | number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Idempotency of pushed changes (offline-sync plan §3.7): the mutation is claimed BEFORE the record is written, under a
|
|
33
|
+
* unique key, so two tabs sending the same change at once end in one write — the second finds the claim and is told to
|
|
34
|
+
* retry, then finds the outcome. A record written after the write could not give that: both requests would miss it.
|
|
35
|
+
*
|
|
36
|
+
* A claim older than the claim timeout belongs to a request that died between claiming and recording; it is taken over
|
|
37
|
+
* with a conditional write on `attempt`, which exactly one contender wins. An entry past `expiresAt` counts as absent
|
|
38
|
+
* whether or not the TTL has removed it yet.
|
|
39
|
+
*/
|
|
40
|
+
export class CoreSyncMutationStore {
|
|
41
|
+
constructor(
|
|
42
|
+
protected readonly store: DataStore<MutationRecord>,
|
|
43
|
+
protected readonly options: { claimTimeoutMs: number; retentionMs: number },
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The key of a mutation: model, user, record and mutation id, hashed to a fixed length. The user is part of it, so a
|
|
48
|
+
* mutation id of somebody else can neither block a change nor reveal its outcome.
|
|
49
|
+
*/
|
|
50
|
+
static key(model: string, userId: string, recordId: string, mutationId: string): string {
|
|
51
|
+
return createHash('sha256').update([model, userId, recordId, mutationId].join('\u0000')).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async claim(key: string, now = new Date()): Promise<SyncMutationClaim> {
|
|
55
|
+
// A concurrent claimant can make each step fail once; three rounds settle every interleaving of two requests
|
|
56
|
+
for (let round = 0; round < 3; round++) {
|
|
57
|
+
const existing = await this.store.findOne({ key });
|
|
58
|
+
if (!existing) {
|
|
59
|
+
try {
|
|
60
|
+
await this.store.insert({ key, ...this.pending(now, 1) });
|
|
61
|
+
return { kind: 'claimed' };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!isDuplicateKey(error)) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (isExpired(existing, now)) {
|
|
70
|
+
if (await this.takeOver(existing, now)) {
|
|
71
|
+
return { kind: 'claimed' };
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (existing.state === 'done' && existing.status) {
|
|
76
|
+
return { kind: 'done', outcome: outcomeOf(existing) };
|
|
77
|
+
}
|
|
78
|
+
const claimedAt = existing.claimedAt ? new Date(existing.claimedAt).getTime() : 0;
|
|
79
|
+
if (now.getTime() - claimedAt < this.options.claimTimeoutMs) {
|
|
80
|
+
return { kind: 'busy' };
|
|
81
|
+
}
|
|
82
|
+
return (await this.takeOver(existing, now)) ? { kind: 'claimed' } : { kind: 'busy' };
|
|
83
|
+
}
|
|
84
|
+
return { kind: 'busy' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Record the outcome of a claimed mutation; kept for the retention period from now */
|
|
88
|
+
async complete(key: string, outcome: SyncMutationOutcome, now = new Date()): Promise<void> {
|
|
89
|
+
await this.store.updateOne(
|
|
90
|
+
{ key },
|
|
91
|
+
{
|
|
92
|
+
$set: {
|
|
93
|
+
code: outcome.code ?? null,
|
|
94
|
+
expiresAt: new Date(now.getTime() + this.options.retentionMs),
|
|
95
|
+
state: 'done',
|
|
96
|
+
status: outcome.status,
|
|
97
|
+
version: outcome.version ?? null,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Give up a claim whose write did not happen (a transient failure), so the client's retry is written anew */
|
|
104
|
+
async release(key: string): Promise<void> {
|
|
105
|
+
await this.store.deleteOne({ key, state: 'pending' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected pending(now: Date, attempt: number): Omit<MutationRecord, 'key'> {
|
|
109
|
+
return {
|
|
110
|
+
attempt,
|
|
111
|
+
claimedAt: now,
|
|
112
|
+
code: null,
|
|
113
|
+
expiresAt: new Date(now.getTime() + this.options.retentionMs),
|
|
114
|
+
state: 'pending',
|
|
115
|
+
status: null,
|
|
116
|
+
version: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Take an abandoned or expired entry over: conditional on the attempt the reader saw, so one contender wins */
|
|
121
|
+
protected async takeOver(existing: StoredRecord<MutationRecord>, now: Date): Promise<boolean> {
|
|
122
|
+
const attempt = existing.attempt ?? 0;
|
|
123
|
+
const taken = await this.store.updateOne(
|
|
124
|
+
{ attempt: existing.attempt ?? null, id: existing.id },
|
|
125
|
+
{ $set: this.pending(now, attempt + 1) },
|
|
126
|
+
);
|
|
127
|
+
return !!taken;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isExpired(record: MutationRecord, now: Date): boolean {
|
|
132
|
+
return !!record.expiresAt && new Date(record.expiresAt).getTime() <= now.getTime();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function outcomeOf(record: MutationRecord): SyncMutationOutcome {
|
|
136
|
+
return {
|
|
137
|
+
...(record.code ? { code: record.code } : {}),
|
|
138
|
+
status: record.status,
|
|
139
|
+
...(typeof record.version === 'number' ? { version: record.version } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A unique index refused the write — MongoDB's code, which the SQL stores carry too (`DuplicateKeyError`) */
|
|
144
|
+
export function isDuplicateKey(error: unknown): boolean {
|
|
145
|
+
return (error as { code?: unknown })?.code === 11000;
|
|
146
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DataStore } from '../../common/persistence/data-store.interface.js';
|
|
2
|
+
import { isDuplicateKey } from './core-sync-mutation.store.js';
|
|
3
|
+
|
|
4
|
+
interface StateRecord {
|
|
5
|
+
epoch?: null | number;
|
|
6
|
+
horizon?: Date | null;
|
|
7
|
+
key: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Access epochs and tombstone horizons (offline-sync plan §3.7, §3.8), keyed by string:
|
|
12
|
+
*
|
|
13
|
+
* - `epoch:t:<model>:<tenant>` — bumped when what a tenant's members may see changes (`invalidateTenant()`, a record
|
|
14
|
+
* moved to another tenant). A cursor carries the epochs it was issued under; another value answers 410 `access`.
|
|
15
|
+
* - `epoch:u:<model>:<tenant>:<user>` — bumped when a scope field (`sync.scope`) changes away from that user, so only
|
|
16
|
+
* that user's cursors resync.
|
|
17
|
+
* - `horizon:<model>` — the oldest point a cursor may continue from; a cursor whose state is older answers 410 `horizon`.
|
|
18
|
+
*
|
|
19
|
+
* A bump is one atomic increment; a key that does not exist yet is created with a duplicate-key retry, so two
|
|
20
|
+
* concurrent bumps both count.
|
|
21
|
+
*/
|
|
22
|
+
export class CoreSyncStateStore {
|
|
23
|
+
constructor(protected readonly store: DataStore<StateRecord>) {}
|
|
24
|
+
|
|
25
|
+
static tenantEpochKey(model: string, tenantId: string): string {
|
|
26
|
+
return `epoch:t:${model}:${tenantId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static userEpochKey(model: string, tenantId: string, userId: string): string {
|
|
30
|
+
return `epoch:u:${model}:${tenantId}:${userId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static horizonKey(model: string): string {
|
|
34
|
+
return `horizon:${model}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The current epochs of `keys`; a key never bumped is `0` */
|
|
38
|
+
async epochs(keys: readonly string[]): Promise<Map<string, number>> {
|
|
39
|
+
const result = new Map<string, number>(keys.map((key) => [key, 0]));
|
|
40
|
+
if (!keys.length) {
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
for (const row of await this.store.find({ key: { $in: [...keys] } }, { select: ['epoch', 'key'] })) {
|
|
44
|
+
result.set(row.key, typeof row.epoch === 'number' ? row.epoch : 0);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async bump(key: string): Promise<void> {
|
|
50
|
+
if (await this.store.updateOne({ key }, { $inc: { epoch: 1 } })) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await this.store.insert({ epoch: 1, key });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!isDuplicateKey(error)) {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
// Created concurrently: this bump still counts
|
|
60
|
+
await this.store.updateOne({ key }, { $inc: { epoch: 1 } });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The tombstone horizon of a model, `undefined` while no purge ran */
|
|
65
|
+
async horizon(model: string): Promise<Date | undefined> {
|
|
66
|
+
const row = await this.store.findOne({ key: CoreSyncStateStore.horizonKey(model) });
|
|
67
|
+
return row?.horizon ? new Date(row.horizon) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Move the horizon forward to `at` — never back, so a slower purge cannot reopen a gap a faster one closed */
|
|
71
|
+
async advanceHorizon(model: string, at: Date): Promise<void> {
|
|
72
|
+
const key = CoreSyncStateStore.horizonKey(model);
|
|
73
|
+
const behind = { $or: [{ horizon: null }, { horizon: { $lt: at } }], key };
|
|
74
|
+
if (await this.store.updateOne(behind, { $set: { horizon: at } })) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (await this.store.findOne({ key })) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await this.store.insert({ horizon: at, key });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!isDuplicateKey(error)) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
await this.store.updateOne(behind, { $set: { horizon: at } });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|