velocious 1.0.492 → 1.0.494
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/database/drivers/base.js +6 -0
- package/build/database/query/insert-base.js +1 -1
- package/build/src/database/drivers/base.d.ts +5 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +6 -1
- package/build/src/database/query/insert-base.js +2 -2
- package/build/src/sync/server-sequence-allocator.d.ts +106 -0
- package/build/src/sync/server-sequence-allocator.d.ts.map +1 -0
- package/build/src/sync/server-sequence-allocator.js +220 -0
- package/build/src/sync/sync-attribute-normalizer.d.ts +105 -0
- package/build/src/sync/sync-attribute-normalizer.d.ts.map +1 -0
- package/build/src/sync/sync-attribute-normalizer.js +172 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts +116 -7
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +103 -10
- 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 +48 -0
- package/build/src/sync/sync-resource-base.d.ts.map +1 -1
- package/build/src/sync/sync-resource-base.js +89 -1
- package/build/sync/server-sequence-allocator.js +246 -0
- package/build/sync/sync-attribute-normalizer.js +186 -0
- package/build/sync/sync-envelope-replay-service.js +114 -9
- package/build/sync/sync-replay-upsert-applier.js +179 -0
- package/build/sync/sync-resource-base.js +103 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/database/drivers/base.js +6 -0
- package/src/database/query/insert-base.js +1 -1
- package/src/sync/server-sequence-allocator.js +246 -0
- package/src/sync/sync-attribute-normalizer.js +186 -0
- package/src/sync/sync-envelope-replay-service.js +114 -9
- package/src/sync/sync-replay-upsert-applier.js +179 -0
- package/src/sync/sync-resource-base.js +103 -0
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import {forcedNonBlankStringParam} from "typanic"
|
|
4
4
|
|
|
5
5
|
import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
|
|
6
|
+
import normalizeAttributesWithSchema from "./sync-attribute-normalizer.js"
|
|
6
7
|
import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
|
|
8
|
+
import VelociousError from "../velocious-error.js"
|
|
9
|
+
|
|
10
|
+
const QUICK_SEARCH_COLUMN = "quickSearch"
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Optional client-declared sync scope carried on a changes request.
|
|
@@ -22,6 +26,105 @@ import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
|
|
|
22
26
|
* @augments {FrontendModelBaseResource<TModelClass>}
|
|
23
27
|
*/
|
|
24
28
|
export default class SyncResourceBase extends FrontendModelBaseResource {
|
|
29
|
+
/**
|
|
30
|
+
* Declarative quick-search text columns. When declared, an index search on
|
|
31
|
+
* the pseudo-column `quickSearch` expands to an OR of LIKE conditions over
|
|
32
|
+
* these root-table columns instead of hitting the controller default.
|
|
33
|
+
* @type {string[] | null} */
|
|
34
|
+
static quickSearchColumns = null
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Declarative writable-attribute schema consumed by
|
|
38
|
+
* {@link SyncResourceBase#normalizeWritableAttributes}, keyed by camelCase
|
|
39
|
+
* attribute name.
|
|
40
|
+
* @type {Record<string, import("./sync-attribute-normalizer.js").SyncAttributeSchemaEntry> | null} */
|
|
41
|
+
static writableAttributes = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Applies frontend-model index searches, expanding declared quick searches.
|
|
45
|
+
* @param {object} args - Search args.
|
|
46
|
+
* @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceController} args.controller - Controller handling the query.
|
|
47
|
+
* @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query instance.
|
|
48
|
+
* @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search params.
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
applyFrontendModelIndexSearch({controller, query, search}) {
|
|
52
|
+
if (this.applyQuickSearch({query, search})) return
|
|
53
|
+
|
|
54
|
+
super.applyFrontendModelIndexSearch({controller, query, search})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Expands a `quickSearch` pseudo-column search into an OR of LIKE conditions
|
|
59
|
+
* over the declared {@link SyncResourceBase.quickSearchColumns}.
|
|
60
|
+
* @param {object} args - Search args.
|
|
61
|
+
* @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query to filter.
|
|
62
|
+
* @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search payload.
|
|
63
|
+
* @returns {boolean} Whether the search was handled as a quick search.
|
|
64
|
+
*/
|
|
65
|
+
applyQuickSearch({query, search}) {
|
|
66
|
+
const quickSearchColumns = /** @type {typeof SyncResourceBase} */ (this.constructor).quickSearchColumns
|
|
67
|
+
|
|
68
|
+
if (!quickSearchColumns || quickSearchColumns.length === 0) return false
|
|
69
|
+
if (search.path.length > 0 || search.column !== QUICK_SEARCH_COLUMN) return false
|
|
70
|
+
|
|
71
|
+
if (search.operator !== "like") {
|
|
72
|
+
throw VelociousError.safe("Sync quick search must use the like operator.", {code: "sync-invalid-quick-search"})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof search.value !== "string") {
|
|
76
|
+
throw VelociousError.safe("Sync quick search must be a string.", {code: "sync-invalid-quick-search"})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const trimmedValue = search.value.trim()
|
|
80
|
+
|
|
81
|
+
if (!trimmedValue) return true
|
|
82
|
+
|
|
83
|
+
const tableSql = query.driver.quoteTable(query.getTableReferenceForJoin())
|
|
84
|
+
const likeValue = `%${trimmedValue}%`
|
|
85
|
+
const conditions = quickSearchColumns.map((columnName) => (
|
|
86
|
+
`${tableSql}.${query.driver.quoteColumn(columnName)} LIKE ${query.driver.quote(likeValue)}`
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
query.where(`(${conditions.join(" OR ")})`)
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalizes incoming writable attributes through the declared
|
|
96
|
+
* {@link SyncResourceBase.writableAttributes} schema: camelCase and
|
|
97
|
+
* snake_case input keys are accepted, values are validated per type and the
|
|
98
|
+
* normalized values are written under snake_case column keys. Validation
|
|
99
|
+
* failures throw client-safe errors built by
|
|
100
|
+
* {@link SyncResourceBase#writableAttributeError}.
|
|
101
|
+
* @param {Record<string, ?>} attributes - Raw incoming attributes.
|
|
102
|
+
* @param {{unknownAttributes?: "error" | "ignore"}} [options] - Unknown input-key handling. Defaults to "error".
|
|
103
|
+
* @returns {Record<string, ?>} Normalized attributes keyed by column names.
|
|
104
|
+
*/
|
|
105
|
+
normalizeWritableAttributes(attributes, options = {}) {
|
|
106
|
+
const schema = /** @type {typeof SyncResourceBase} */ (this.constructor).writableAttributes
|
|
107
|
+
|
|
108
|
+
if (!schema) throw new Error(`${this.constructor.name} must define static writableAttributes to use normalizeWritableAttributes`)
|
|
109
|
+
|
|
110
|
+
return normalizeAttributesWithSchema({
|
|
111
|
+
attributes,
|
|
112
|
+
errorFactory: (message, details) => this.writableAttributeError(message, details),
|
|
113
|
+
schema,
|
|
114
|
+
unknownAttributes: options.unknownAttributes
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Builds the client-safe error thrown for a failed writable-attribute validation.
|
|
120
|
+
* @param {string} message - Human-readable validation message.
|
|
121
|
+
* @param {{cause?: Error, code: string}} details - Stable machine-readable code and optional cause.
|
|
122
|
+
* @returns {Error} Client-safe error.
|
|
123
|
+
*/
|
|
124
|
+
writableAttributeError(message, {cause, code}) {
|
|
125
|
+
return VelociousError.safe(message, cause ? {cause, code} : {code})
|
|
126
|
+
}
|
|
127
|
+
|
|
25
128
|
/**
|
|
26
129
|
* Returns a stable change-feed page after app authorization.
|
|
27
130
|
* @returns {Promise<Record<string, ?>>} Change-feed page result.
|