velocious 1.0.489 → 1.0.493
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/build/application.js +3 -0
- package/build/cli/commands/db/tenants/create.js +7 -5
- package/build/cli/commands/db/tenants/drop.js +6 -6
- package/build/configuration-types.js +8 -0
- package/build/configuration.js +29 -0
- package/build/database/query/index.js +30 -0
- package/build/database/query/model-class-query.js +17 -0
- package/build/database/record/index.js +16 -0
- package/build/src/application.d.ts.map +1 -1
- package/build/src/application.js +3 -1
- package/build/src/cli/commands/db/tenants/create.d.ts.map +1 -1
- package/build/src/cli/commands/db/tenants/create.js +8 -5
- package/build/src/cli/commands/db/tenants/drop.d.ts +1 -1
- package/build/src/cli/commands/db/tenants/drop.d.ts.map +1 -1
- package/build/src/cli/commands/db/tenants/drop.js +7 -6
- package/build/src/configuration-types.d.ts +24 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +8 -1
- package/build/src/configuration.d.ts +6 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +26 -1
- package/build/src/database/query/index.d.ts +25 -0
- package/build/src/database/query/index.d.ts.map +1 -1
- package/build/src/database/query/index.js +26 -1
- package/build/src/database/query/model-class-query.d.ts +10 -0
- package/build/src/database/query/model-class-query.d.ts.map +1 -1
- package/build/src/database/query/model-class-query.js +16 -1
- package/build/src/database/record/index.d.ts +7 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +15 -1
- package/build/src/sync/query-scope.d.ts +18 -0
- package/build/src/sync/query-scope.d.ts.map +1 -0
- package/build/src/sync/query-scope.js +77 -0
- package/build/src/sync/server-change-feed.d.ts.map +1 -1
- package/build/src/sync/server-change-feed.js +2 -24
- package/build/src/sync/stable-json.d.ts +9 -0
- package/build/src/sync/stable-json.d.ts.map +1 -0
- package/build/src/sync/stable-json.js +27 -0
- package/build/src/sync/sync-api-client.d.ts +185 -5
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +309 -17
- package/build/src/sync/sync-api-controller.d.ts +8 -0
- package/build/src/sync/sync-api-controller.d.ts.map +1 -1
- package/build/src/sync/sync-api-controller.js +17 -1
- package/build/src/sync/sync-client-registry.d.ts +12 -0
- package/build/src/sync/sync-client-registry.d.ts.map +1 -0
- package/build/src/sync/sync-client-registry.js +27 -0
- package/build/src/sync/sync-client-types.d.ts +127 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -0
- package/build/src/sync/sync-client-types.js +3 -0
- package/build/src/sync/sync-client.d.ts +176 -0
- package/build/src/sync/sync-client.d.ts.map +1 -0
- package/build/src/sync/sync-client.js +380 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts +153 -10
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +174 -14
- package/build/src/sync/sync-replay-upsert-applier.d.ts +102 -0
- package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -0
- package/build/src/sync/sync-replay-upsert-applier.js +158 -0
- package/build/src/sync/sync-resource-base.d.ts +98 -0
- package/build/src/sync/sync-resource-base.d.ts.map +1 -0
- package/build/src/sync/sync-resource-base.js +122 -0
- package/build/src/sync/sync-scope-store.d.ts +128 -0
- package/build/src/sync/sync-scope-store.d.ts.map +1 -0
- package/build/src/sync/sync-scope-store.js +282 -0
- package/build/src/tenants/default-tenant-database-provisioning.d.ts +21 -0
- package/build/src/tenants/default-tenant-database-provisioning.d.ts.map +1 -0
- package/build/src/tenants/default-tenant-database-provisioning.js +106 -0
- package/build/src/tenants/tenant.d.ts.map +1 -1
- package/build/src/tenants/tenant.js +6 -5
- package/build/sync/query-scope.js +84 -0
- package/build/sync/server-change-feed.js +1 -25
- package/build/sync/stable-json.js +28 -0
- package/build/sync/sync-api-client.js +331 -17
- package/build/sync/sync-api-controller.js +20 -0
- package/build/sync/sync-client-registry.js +30 -0
- package/build/sync/sync-client-types.js +41 -0
- package/build/sync/sync-client.js +433 -0
- package/build/sync/sync-envelope-replay-service.js +190 -13
- package/build/sync/sync-replay-upsert-applier.js +179 -0
- package/build/sync/sync-resource-base.js +143 -0
- package/build/sync/sync-scope-store.js +327 -0
- package/build/tenants/default-tenant-database-provisioning.js +124 -0
- package/build/tenants/tenant.js +5 -5
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/application.js +3 -0
- package/src/cli/commands/db/tenants/create.js +7 -5
- package/src/cli/commands/db/tenants/drop.js +6 -6
- package/src/configuration-types.js +8 -0
- package/src/configuration.js +29 -0
- package/src/database/query/index.js +30 -0
- package/src/database/query/model-class-query.js +17 -0
- package/src/database/record/index.js +16 -0
- package/src/sync/query-scope.js +84 -0
- package/src/sync/server-change-feed.js +1 -25
- package/src/sync/stable-json.js +28 -0
- package/src/sync/sync-api-client.js +331 -17
- package/src/sync/sync-api-controller.js +20 -0
- package/src/sync/sync-client-registry.js +30 -0
- package/src/sync/sync-client-types.js +41 -0
- package/src/sync/sync-client.js +433 -0
- package/src/sync/sync-envelope-replay-service.js +190 -13
- package/src/sync/sync-replay-upsert-applier.js +179 -0
- package/src/sync/sync-resource-base.js +143 -0
- package/src/sync/sync-scope-store.js +327 -0
- package/src/tenants/default-tenant-database-provisioning.js +124 -0
- package/src/tenants/tenant.js +5 -5
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {optionalFloat, optionalInteger, optionalString} from "typanic"
|
|
4
|
+
|
|
5
|
+
import SyncApiClient from "./sync-api-client.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Coercers for the declarative sync field types. All are present-key based:
|
|
9
|
+
* a field is only applied when its key exists in the mutation data.
|
|
10
|
+
* @type {Record<string, (value: ?, label: string) => ?>}
|
|
11
|
+
*/
|
|
12
|
+
const FIELD_TYPES = {
|
|
13
|
+
booleanOrNull: (value, label) => SyncApiClient.optionalBooleanSyncValue(value, label),
|
|
14
|
+
dateOrNull: (value, label) => optionalSyncDate(value, label),
|
|
15
|
+
floatOrNull: (value, label) => optionalFloat(value, label),
|
|
16
|
+
integerOrNull: (value, label) => optionalInteger(value, label),
|
|
17
|
+
raw: (value) => value,
|
|
18
|
+
stringOrNull: (value, label) => optionalString(value, label)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parses an optional date value, failing loudly on invalid input.
|
|
23
|
+
* @param {?} value - Date, parseable string, or epoch number.
|
|
24
|
+
* @param {string} label - Field label for error messages.
|
|
25
|
+
* @returns {Date | null} Parsed date or null when absent.
|
|
26
|
+
*/
|
|
27
|
+
function optionalSyncDate(value, label) {
|
|
28
|
+
if (value === null || value === undefined) return null
|
|
29
|
+
|
|
30
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
|
|
31
|
+
|
|
32
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
33
|
+
const dateValue = new Date(value)
|
|
34
|
+
|
|
35
|
+
if (!Number.isNaN(dateValue.getTime())) return dateValue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw new Error(`Expected ${label} to be a valid date or null`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Declarative field-map upsert applier for sync replay mutations.
|
|
43
|
+
*
|
|
44
|
+
* Owns the generic mechanics every per-resource replay handler repeats:
|
|
45
|
+
* present-key filtering, per-field type coercion, unknown-key rejection, the
|
|
46
|
+
* find-or-create upsert, the delete branch, optional snapshot serialization,
|
|
47
|
+
* and the domain after-apply tail. Apps declare only the field map and hooks.
|
|
48
|
+
*/
|
|
49
|
+
export default class SyncReplayUpsertApplier {
|
|
50
|
+
/**
|
|
51
|
+
* Creates a declarative upsert applier.
|
|
52
|
+
* @param {object} args - Applier configuration.
|
|
53
|
+
* @param {?} args.modelClass - Model class receiving the mutations.
|
|
54
|
+
* @param {Record<string, string | {type: string, column?: string} | ((value: ?, label: string) => ?)>} args.fields - Data-key → field-type map: a named type, a {type, column} rename spec, "ignored" for accepted-but-dropped keys, or a custom coercer function.
|
|
55
|
+
* @param {(args: {data: Record<string, ?>, mutation: ?, context: Record<string, ?>}) => Promise<?>} [args.findRecord] - Custom record resolver. Defaults to findBy({id: mutation.resourceId}).
|
|
56
|
+
* @param {(args: {mutation: ?, context: Record<string, ?>}) => Promise<?>} [args.findRecordForDelete] - Custom delete resolver. Defaults to findRecord.
|
|
57
|
+
* @param {"error" | "ignore"} [args.restArgs] - Unknown data-key handling. Defaults to "error".
|
|
58
|
+
* @param {(args: {mappedAttributes: Record<string, ?>, mutation: ?, context: Record<string, ?>, record: ?}) => Promise<Record<string, ?> | void>} [args.afterApply] - Domain tail; its returned object merges into the apply result.
|
|
59
|
+
* @param {(args: {mutation: ?, context: Record<string, ?>, record: ?}) => Promise<Record<string, ?>> | Record<string, ?>} [args.serialize] - Snapshot serializer; result lands on applyResult.serializedData.
|
|
60
|
+
*/
|
|
61
|
+
constructor({afterApply, fields, findRecord, findRecordForDelete, modelClass, restArgs = "error", serialize}) {
|
|
62
|
+
if (!modelClass) throw new Error("SyncReplayUpsertApplier requires a modelClass")
|
|
63
|
+
if (!fields || typeof fields !== "object") throw new Error("SyncReplayUpsertApplier requires a fields map")
|
|
64
|
+
if (restArgs !== "error" && restArgs !== "ignore") {
|
|
65
|
+
throw new Error(`SyncReplayUpsertApplier restArgs must be "error" or "ignore", got: ${String(restArgs)}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const [fieldName, fieldSpec] of Object.entries(fields)) {
|
|
69
|
+
if (typeof fieldSpec === "function") continue
|
|
70
|
+
|
|
71
|
+
const fieldType = typeof fieldSpec === "string" ? fieldSpec : fieldSpec.type
|
|
72
|
+
|
|
73
|
+
if (fieldType !== "ignored" && !(fieldType in FIELD_TYPES)) {
|
|
74
|
+
throw new Error(`Unknown sync field type: ${fieldType} for: ${fieldName}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.afterApply = afterApply
|
|
79
|
+
this.fields = fields
|
|
80
|
+
this.findRecord = findRecord
|
|
81
|
+
this.findRecordForDelete = findRecordForDelete
|
|
82
|
+
this.modelClass = modelClass
|
|
83
|
+
this.restArgs = restArgs
|
|
84
|
+
this.serialize = serialize
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Applies one normalized replay mutation to the declared model.
|
|
89
|
+
* @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: ?}} args - Replay apply arguments.
|
|
90
|
+
* @returns {Promise<Record<string, ?>>} Apply result with the record, created/deleted flags, optional serializedData, and afterApply extras.
|
|
91
|
+
*/
|
|
92
|
+
async apply({context, mutation}) {
|
|
93
|
+
if (mutation.syncType === "delete") return await this.applyDelete({context, mutation})
|
|
94
|
+
|
|
95
|
+
const mappedAttributes = this.mappedAttributes(mutation.data)
|
|
96
|
+
const record = this.findRecord
|
|
97
|
+
? await this.findRecord({context, data: mutation.data, mutation})
|
|
98
|
+
: await this.modelClass.findBy({id: mutation.resourceId})
|
|
99
|
+
/** @type {Record<string, ?>} */
|
|
100
|
+
const result = {created: false, deleted: false, record: null}
|
|
101
|
+
|
|
102
|
+
if (record) {
|
|
103
|
+
record.assign(mappedAttributes)
|
|
104
|
+
await record.save()
|
|
105
|
+
result.record = record
|
|
106
|
+
} else {
|
|
107
|
+
result.record = await this.modelClass.create({id: mutation.resourceId, ...mappedAttributes})
|
|
108
|
+
result.created = true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (this.serialize) result.serializedData = await this.serialize({context, mutation, record: result.record})
|
|
112
|
+
|
|
113
|
+
if (this.afterApply) {
|
|
114
|
+
const afterApplyResult = await this.afterApply({context, mappedAttributes, mutation, record: result.record})
|
|
115
|
+
|
|
116
|
+
if (afterApplyResult && typeof afterApplyResult === "object") Object.assign(result, afterApplyResult)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Applies a delete mutation to the declared model.
|
|
124
|
+
* @param {{context: Record<string, ?>, mutation: ?}} args - Delete arguments.
|
|
125
|
+
* @returns {Promise<Record<string, ?>>} Apply result with the deleted flag.
|
|
126
|
+
*/
|
|
127
|
+
async applyDelete({context, mutation}) {
|
|
128
|
+
const resolveRecord = this.findRecordForDelete || this.findRecord
|
|
129
|
+
const record = resolveRecord
|
|
130
|
+
? await resolveRecord({context, data: mutation.data, mutation})
|
|
131
|
+
: await this.modelClass.findBy({id: mutation.resourceId})
|
|
132
|
+
|
|
133
|
+
if (!record) return {created: false, deleted: false, record: null}
|
|
134
|
+
|
|
135
|
+
await record.destroy()
|
|
136
|
+
|
|
137
|
+
return {created: false, deleted: true, record}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Maps present data keys through the declared field types.
|
|
142
|
+
* @param {Record<string, ?>} data - Normalized mutation data.
|
|
143
|
+
* @returns {Record<string, ?>} Coerced attributes keyed by column name.
|
|
144
|
+
*/
|
|
145
|
+
mappedAttributes(data) {
|
|
146
|
+
/** @type {Record<string, ?>} */
|
|
147
|
+
const mappedAttributes = {}
|
|
148
|
+
/** @type {string[]} */
|
|
149
|
+
const unknownKeys = []
|
|
150
|
+
|
|
151
|
+
for (const [dataKey, value] of Object.entries(data)) {
|
|
152
|
+
const fieldSpec = this.fields[dataKey]
|
|
153
|
+
|
|
154
|
+
if (!fieldSpec) {
|
|
155
|
+
unknownKeys.push(dataKey)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof fieldSpec === "function") {
|
|
160
|
+
mappedAttributes[dataKey] = fieldSpec(value, dataKey)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fieldType = typeof fieldSpec === "string" ? fieldSpec : fieldSpec.type
|
|
165
|
+
|
|
166
|
+
if (fieldType === "ignored") continue
|
|
167
|
+
|
|
168
|
+
const column = typeof fieldSpec === "string" ? dataKey : fieldSpec.column || dataKey
|
|
169
|
+
|
|
170
|
+
mappedAttributes[column] = FIELD_TYPES[fieldType](value, dataKey)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (unknownKeys.length > 0 && this.restArgs === "error") {
|
|
174
|
+
throw new Error(`Unknown sync data keys: ${unknownKeys.join(", ")}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return mappedAttributes
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {forcedNonBlankStringParam} from "typanic"
|
|
4
|
+
|
|
5
|
+
import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
|
|
6
|
+
import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Optional client-declared sync scope carried on a changes request.
|
|
10
|
+
* @typedef {object} SerializedChangesScope
|
|
11
|
+
* @property {Record<string, ?>} conditions - Plain attribute conditions from the client query.
|
|
12
|
+
* @property {string} resourceType - Client resource/model name the scope was declared for.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Base resource for Velocious sync endpoints.
|
|
17
|
+
*
|
|
18
|
+
* Velocious owns the changes/replay orchestration (scope parsing, feed paging,
|
|
19
|
+
* replay delegation, response shape) while apps subclass and only declare
|
|
20
|
+
* authorization, feed scoping, and their replay service.
|
|
21
|
+
* @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
|
|
22
|
+
* @augments {FrontendModelBaseResource<TModelClass>}
|
|
23
|
+
*/
|
|
24
|
+
export default class SyncResourceBase extends FrontendModelBaseResource {
|
|
25
|
+
/**
|
|
26
|
+
* Returns a stable change-feed page after app authorization.
|
|
27
|
+
* @returns {Promise<Record<string, ?>>} Change-feed page result.
|
|
28
|
+
*/
|
|
29
|
+
async changes() {
|
|
30
|
+
const params = this.params()
|
|
31
|
+
const scope = this.changesScope(params)
|
|
32
|
+
|
|
33
|
+
await this.authorizeChanges({params, scope})
|
|
34
|
+
|
|
35
|
+
return await this.changeFeedService({params, scope}).changes()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Replays client sync envelopes through the app replay service.
|
|
40
|
+
* @returns {Promise<Record<string, ?>>} Replay result with per-sync states.
|
|
41
|
+
*/
|
|
42
|
+
async replay() {
|
|
43
|
+
const result = await this.buildReplayService().replay(this.params())
|
|
44
|
+
|
|
45
|
+
if (result.status === "error") return result
|
|
46
|
+
|
|
47
|
+
return {status: "success", syncs: result.syncs}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parses the optional client-declared scope from request params.
|
|
52
|
+
* @param {Record<string, ?>} params - Request params.
|
|
53
|
+
* @returns {SerializedChangesScope | null} Parsed scope, or null when the client sent none.
|
|
54
|
+
*/
|
|
55
|
+
changesScope(params) {
|
|
56
|
+
const scope = params.scope
|
|
57
|
+
|
|
58
|
+
if (scope === undefined || scope === null) return null
|
|
59
|
+
|
|
60
|
+
if (typeof scope !== "object" || Array.isArray(scope)) {
|
|
61
|
+
throw new Error(`Sync changes scope must be an object, got: ${String(scope)}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scopeParams = /** @type {Record<string, ?>} */ (scope)
|
|
65
|
+
const resourceType = forcedNonBlankStringParam(scopeParams, "resourceType")
|
|
66
|
+
const conditions = scopeParams.conditions
|
|
67
|
+
|
|
68
|
+
if (!conditions || typeof conditions !== "object" || Array.isArray(conditions)) {
|
|
69
|
+
throw new Error(`Sync changes scope.conditions must be an object, got: ${String(conditions)}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {conditions: /** @type {Record<string, ?>} */ (conditions), resourceType}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Builds the change-feed service serving this changes request.
|
|
77
|
+
* @param {{params: Record<string, ?>, scope: SerializedChangesScope | null}} args - Request params and parsed scope.
|
|
78
|
+
* @returns {{changes: () => Promise<Record<string, ?>>}} Change-feed service.
|
|
79
|
+
*/
|
|
80
|
+
changeFeedService({params, scope}) {
|
|
81
|
+
return new SyncModelChangeFeedService({
|
|
82
|
+
modelClass: this.syncModelClass(),
|
|
83
|
+
params,
|
|
84
|
+
scopeQuery: ({query}) => this.scopeChangesQuery({params, query, scope})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds the app replay service handling this replay request.
|
|
90
|
+
* @returns {import("./sync-envelope-replay-service.js").default} Replay service instance.
|
|
91
|
+
*/
|
|
92
|
+
buildReplayService() {
|
|
93
|
+
const ReplayServiceClass = this.replayServiceClass()
|
|
94
|
+
|
|
95
|
+
return new ReplayServiceClass(this.replayServiceArgs())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns constructor args for the app replay service.
|
|
100
|
+
* @returns {Record<string, ?>} Replay service constructor args.
|
|
101
|
+
*/
|
|
102
|
+
replayServiceArgs() {
|
|
103
|
+
return {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns the sync model class backing the change feed.
|
|
108
|
+
* @returns {typeof import("../database/record/index.js").default} Sync model class.
|
|
109
|
+
*/
|
|
110
|
+
syncModelClass() {
|
|
111
|
+
const modelClass = /** @type {typeof SyncResourceBase} */ (this.constructor).ModelClass
|
|
112
|
+
|
|
113
|
+
if (!modelClass) throw new Error(`${this.constructor.name} must define static ModelClass`)
|
|
114
|
+
|
|
115
|
+
return modelClass
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Authorizes the current context for reading the requested changes.
|
|
120
|
+
* @param {{params: Record<string, ?>, scope: SerializedChangesScope | null}} _args - Request params and parsed scope.
|
|
121
|
+
* @returns {Promise<void>} Resolves when access is allowed; throws otherwise.
|
|
122
|
+
*/
|
|
123
|
+
async authorizeChanges(_args) {
|
|
124
|
+
throw new Error("SyncResourceBase#authorizeChanges must be implemented")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Applies app visibility scoping onto the change-feed query.
|
|
129
|
+
* @param {{params: Record<string, ?>, query: import("../database/query/model-class-query.js").default, scope: SerializedChangesScope | null}} _args - Request params, feed query, and parsed scope.
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
scopeChangesQuery(_args) {
|
|
133
|
+
throw new Error("SyncResourceBase#scopeChangesQuery must be implemented")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns the app replay service class handling replay mutations.
|
|
138
|
+
* @returns {typeof import("./sync-envelope-replay-service.js").default} Replay service class.
|
|
139
|
+
*/
|
|
140
|
+
replayServiceClass() {
|
|
141
|
+
throw new Error("SyncResourceBase#replayServiceClass must be implemented")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import TableData from "../database/table-data/index.js"
|
|
4
|
+
import UUID from "pure-uuid"
|
|
5
|
+
|
|
6
|
+
import {scopeKey} from "./query-scope.js"
|
|
7
|
+
import stableJsonStringify from "./stable-json.js"
|
|
8
|
+
|
|
9
|
+
const TABLE_NAME = "velocious_sync_scopes"
|
|
10
|
+
const SCOPE_DIGEST_PREFIX = "velocious-sync-scope:"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Digests a serialized scope into a fixed-size deterministic key, so scope
|
|
14
|
+
* identities with long condition values fit an indexed string column.
|
|
15
|
+
* @param {import("./sync-client-types.js").SerializedSyncScope} scope - Serialized sync scope.
|
|
16
|
+
* @returns {string} Deterministic UUIDv5 digest of the canonical scope key.
|
|
17
|
+
*/
|
|
18
|
+
function scopeDigestForScope(scope) {
|
|
19
|
+
return new UUID(5, "ns:URL", `${SCOPE_DIGEST_PREFIX}${scopeKey(scope)}`).format()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} SyncScopeRow
|
|
24
|
+
* @property {Record<string, ?>} conditions - Scope attribute conditions.
|
|
25
|
+
* @property {string | null} cursorPayload - Persisted cursor JSON payload.
|
|
26
|
+
* @property {string} id - Scope row id.
|
|
27
|
+
* @property {string} resourceType - Scope resource/model name.
|
|
28
|
+
* @property {string} scopeDigest - Fixed-size deterministic digest of the canonical scope key.
|
|
29
|
+
* @property {string} state - Scope state ("active" or "removed").
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Framework-owned local persistence for declared sync scopes and their cursors.
|
|
34
|
+
*
|
|
35
|
+
* Backed by an auto-created `velocious_sync_scopes` table on the configured
|
|
36
|
+
* database, with a process-local memory fallback when no database is
|
|
37
|
+
* configured (mirroring the server change-feed store).
|
|
38
|
+
*/
|
|
39
|
+
export default class SyncScopeStore {
|
|
40
|
+
/**
|
|
41
|
+
* Creates a sync scope store.
|
|
42
|
+
* @param {object} args - Options.
|
|
43
|
+
* @param {import("../configuration.js").default} args.configuration - Configuration owning the database.
|
|
44
|
+
* @param {string} [args.databaseIdentifier] - Database identifier.
|
|
45
|
+
*/
|
|
46
|
+
constructor({configuration, databaseIdentifier = "default"}) {
|
|
47
|
+
this.configuration = configuration
|
|
48
|
+
this.databaseIdentifier = databaseIdentifier
|
|
49
|
+
/** @type {Map<string, SyncScopeRow>} */
|
|
50
|
+
this._memoryScopes = new Map()
|
|
51
|
+
this._isReady = false
|
|
52
|
+
/** @type {Promise<void> | null} */
|
|
53
|
+
this._readyPromise = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensures the backing table exists.
|
|
58
|
+
* @returns {Promise<void>} Resolves when ready.
|
|
59
|
+
*/
|
|
60
|
+
async ensureReady() {
|
|
61
|
+
if (this._isReady) return
|
|
62
|
+
|
|
63
|
+
if (this._usesMemoryStorage()) {
|
|
64
|
+
this._isReady = true
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this._readyPromise) return await this._readyPromise
|
|
69
|
+
|
|
70
|
+
this._readyPromise = this._withDb(async (db) => {
|
|
71
|
+
await this._ensureScopesTable(db)
|
|
72
|
+
this._isReady = true
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await this._readyPromise
|
|
77
|
+
} finally {
|
|
78
|
+
if (!this._isReady) this._readyPromise = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Finds or creates the scope row for a serialized scope, reactivating removed scopes.
|
|
84
|
+
* @param {import("./sync-client-types.js").SerializedSyncScope} scope - Serialized sync scope.
|
|
85
|
+
* @returns {Promise<SyncScopeRow>} Persisted scope row.
|
|
86
|
+
*/
|
|
87
|
+
async findOrCreateScope(scope) {
|
|
88
|
+
await this.ensureReady()
|
|
89
|
+
|
|
90
|
+
const digest = scopeDigestForScope(scope)
|
|
91
|
+
|
|
92
|
+
if (this._usesMemoryStorage()) {
|
|
93
|
+
const existingScope = this._memoryScopes.get(digest)
|
|
94
|
+
|
|
95
|
+
if (existingScope) {
|
|
96
|
+
if (existingScope.state !== "active") existingScope.state = "active"
|
|
97
|
+
|
|
98
|
+
return existingScope
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const newScope = {
|
|
102
|
+
conditions: scope.conditions,
|
|
103
|
+
cursorPayload: null,
|
|
104
|
+
id: new UUID(4).format(),
|
|
105
|
+
resourceType: scope.resourceType,
|
|
106
|
+
scopeDigest: digest,
|
|
107
|
+
state: "active"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._memoryScopes.set(digest, newScope)
|
|
111
|
+
|
|
112
|
+
return newScope
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return await this._withDb(async (db) => {
|
|
116
|
+
const existingRow = await this._rowByScopeDigest(db, digest)
|
|
117
|
+
|
|
118
|
+
if (existingRow) {
|
|
119
|
+
if (existingRow.state !== "active") {
|
|
120
|
+
await db.update({conditions: {id: existingRow.id}, data: {state: "active", updated_at: new Date()}, tableName: TABLE_NAME})
|
|
121
|
+
existingRow.state = "active"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return existingRow
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await db.insert({
|
|
128
|
+
tableName: TABLE_NAME,
|
|
129
|
+
data: {
|
|
130
|
+
conditions_json: stableJsonStringify(scope.conditions),
|
|
131
|
+
created_at: new Date(),
|
|
132
|
+
cursor_json: null,
|
|
133
|
+
id: new UUID(4).format(),
|
|
134
|
+
resource_type: scope.resourceType,
|
|
135
|
+
scope_digest: digest,
|
|
136
|
+
state: "active",
|
|
137
|
+
updated_at: new Date()
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const createdRow = await this._rowByScopeDigest(db, digest)
|
|
142
|
+
|
|
143
|
+
if (!createdRow) throw new Error("Failed to persist sync scope")
|
|
144
|
+
|
|
145
|
+
return createdRow
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Returns all active scope rows.
|
|
151
|
+
* @returns {Promise<SyncScopeRow[]>} Active scope rows.
|
|
152
|
+
*/
|
|
153
|
+
async activeScopes() {
|
|
154
|
+
await this.ensureReady()
|
|
155
|
+
|
|
156
|
+
if (this._usesMemoryStorage()) {
|
|
157
|
+
return [...this._memoryScopes.values()].filter((scope) => scope.state === "active")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return await this._withDb(async (db) => {
|
|
161
|
+
const rows = /** @type {Array<Record<string, ?>>} */ (await db
|
|
162
|
+
.newQuery()
|
|
163
|
+
.from(TABLE_NAME)
|
|
164
|
+
.where({state: "active"})
|
|
165
|
+
.order("created_at ASC")
|
|
166
|
+
.results())
|
|
167
|
+
|
|
168
|
+
return rows.map((row) => this._normalizeScopeRow(row))
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Loads the persisted cursor payload for a scope row.
|
|
174
|
+
* @param {SyncScopeRow} scopeRow - Scope row.
|
|
175
|
+
* @returns {Promise<string | null>} Cursor JSON payload.
|
|
176
|
+
*/
|
|
177
|
+
async loadCursor(scopeRow) {
|
|
178
|
+
await this.ensureReady()
|
|
179
|
+
|
|
180
|
+
if (this._usesMemoryStorage()) {
|
|
181
|
+
return this._memoryScopes.get(scopeRow.scopeDigest)?.cursorPayload ?? null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return await this._withDb(async (db) => {
|
|
185
|
+
const row = await this._rowByScopeDigest(db, scopeRow.scopeDigest)
|
|
186
|
+
|
|
187
|
+
return row ? row.cursorPayload : null
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Persists the acknowledged cursor for a scope row.
|
|
193
|
+
* @param {SyncScopeRow} scopeRow - Scope row.
|
|
194
|
+
* @param {import("./sync-api-client-types.js").SyncCursor} cursor - Acknowledged cursor.
|
|
195
|
+
* @returns {Promise<void>}
|
|
196
|
+
*/
|
|
197
|
+
async saveCursor(scopeRow, cursor) {
|
|
198
|
+
if (!cursor) return
|
|
199
|
+
|
|
200
|
+
await this.ensureReady()
|
|
201
|
+
|
|
202
|
+
const cursorPayload = JSON.stringify(cursor)
|
|
203
|
+
|
|
204
|
+
if (this._usesMemoryStorage()) {
|
|
205
|
+
const memoryScope = this._memoryScopes.get(scopeRow.scopeDigest)
|
|
206
|
+
|
|
207
|
+
if (!memoryScope) throw new Error(`No sync scope found for: ${scopeRow.scopeDigest}`)
|
|
208
|
+
|
|
209
|
+
memoryScope.cursorPayload = cursorPayload
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await this._withDb(async (db) => {
|
|
214
|
+
await db.update({
|
|
215
|
+
conditions: {scope_digest: scopeRow.scopeDigest},
|
|
216
|
+
data: {cursor_json: cursorPayload, updated_at: new Date()},
|
|
217
|
+
tableName: TABLE_NAME
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Deactivates the scope row for a serialized scope.
|
|
224
|
+
* @param {import("./sync-client-types.js").SerializedSyncScope} scope - Serialized sync scope.
|
|
225
|
+
* @returns {Promise<void>}
|
|
226
|
+
*/
|
|
227
|
+
async deactivate(scope) {
|
|
228
|
+
await this.ensureReady()
|
|
229
|
+
|
|
230
|
+
const digest = scopeDigestForScope(scope)
|
|
231
|
+
|
|
232
|
+
if (this._usesMemoryStorage()) {
|
|
233
|
+
const memoryScope = this._memoryScopes.get(digest)
|
|
234
|
+
|
|
235
|
+
if (memoryScope) memoryScope.state = "removed"
|
|
236
|
+
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await this._withDb(async (db) => {
|
|
241
|
+
await db.update({conditions: {scope_digest: digest}, data: {state: "removed", updated_at: new Date()}, tableName: TABLE_NAME})
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether the store runs without a configured database.
|
|
247
|
+
* @returns {boolean} Whether memory storage is used.
|
|
248
|
+
*/
|
|
249
|
+
_usesMemoryStorage() {
|
|
250
|
+
try {
|
|
251
|
+
return !this.configuration.getDatabaseConfiguration()[this.databaseIdentifier]
|
|
252
|
+
} catch {
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Runs a callback with a database connection.
|
|
259
|
+
* @template Result
|
|
260
|
+
* @param {(db: import("../database/drivers/base.js").default) => Promise<Result>} callback - Database callback.
|
|
261
|
+
* @returns {Promise<Result>} Callback result.
|
|
262
|
+
*/
|
|
263
|
+
async _withDb(callback) {
|
|
264
|
+
return await this.configuration.ensureConnections({name: "Sync scope store"}, async (dbs) => {
|
|
265
|
+
const db = dbs[this.databaseIdentifier]
|
|
266
|
+
|
|
267
|
+
if (!db) throw new Error(`No database connection available for identifier: ${this.databaseIdentifier}`)
|
|
268
|
+
|
|
269
|
+
return await callback(db)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Ensures the scopes table exists.
|
|
275
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
async _ensureScopesTable(db) {
|
|
279
|
+
if (await db.tableExists(TABLE_NAME)) return
|
|
280
|
+
|
|
281
|
+
const table = new TableData(TABLE_NAME, {ifNotExists: true})
|
|
282
|
+
|
|
283
|
+
table.string("id", {null: false, primaryKey: true})
|
|
284
|
+
table.string("scope_digest", {index: true, null: false})
|
|
285
|
+
table.string("resource_type", {index: true, null: false})
|
|
286
|
+
table.text("conditions_json", {null: false})
|
|
287
|
+
table.text("cursor_json", {null: true})
|
|
288
|
+
table.string("state", {index: true, null: false})
|
|
289
|
+
table.datetime("created_at", {null: false})
|
|
290
|
+
table.datetime("updated_at", {null: false})
|
|
291
|
+
|
|
292
|
+
await db.createTable(table)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolves a scope row by its digest.
|
|
297
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
298
|
+
* @param {string} digest - Fixed-size scope digest.
|
|
299
|
+
* @returns {Promise<SyncScopeRow | null>} Scope row or null.
|
|
300
|
+
*/
|
|
301
|
+
async _rowByScopeDigest(db, digest) {
|
|
302
|
+
const rows = /** @type {Array<Record<string, ?>>} */ (await db
|
|
303
|
+
.newQuery()
|
|
304
|
+
.from(TABLE_NAME)
|
|
305
|
+
.where({scope_digest: digest})
|
|
306
|
+
.limit(1)
|
|
307
|
+
.results())
|
|
308
|
+
|
|
309
|
+
return rows[0] ? this._normalizeScopeRow(rows[0]) : null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Normalizes a raw scope table row.
|
|
314
|
+
* @param {Record<string, ?>} row - Raw table row.
|
|
315
|
+
* @returns {SyncScopeRow} Normalized scope row.
|
|
316
|
+
*/
|
|
317
|
+
_normalizeScopeRow(row) {
|
|
318
|
+
return {
|
|
319
|
+
conditions: /** @type {Record<string, ?>} */ (JSON.parse(String(row.conditions_json))),
|
|
320
|
+
cursorPayload: row.cursor_json === null || row.cursor_json === undefined ? null : String(row.cursor_json),
|
|
321
|
+
id: String(row.id),
|
|
322
|
+
resourceType: String(row.resource_type),
|
|
323
|
+
scopeDigest: String(row.scope_digest),
|
|
324
|
+
state: String(row.state)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|