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.
Files changed (35) hide show
  1. package/build/database/drivers/base.js +6 -0
  2. package/build/database/query/insert-base.js +1 -1
  3. package/build/src/database/drivers/base.d.ts +5 -0
  4. package/build/src/database/drivers/base.d.ts.map +1 -1
  5. package/build/src/database/drivers/base.js +6 -1
  6. package/build/src/database/query/insert-base.js +2 -2
  7. package/build/src/sync/server-sequence-allocator.d.ts +106 -0
  8. package/build/src/sync/server-sequence-allocator.d.ts.map +1 -0
  9. package/build/src/sync/server-sequence-allocator.js +220 -0
  10. package/build/src/sync/sync-attribute-normalizer.d.ts +105 -0
  11. package/build/src/sync/sync-attribute-normalizer.d.ts.map +1 -0
  12. package/build/src/sync/sync-attribute-normalizer.js +172 -0
  13. package/build/src/sync/sync-envelope-replay-service.d.ts +116 -7
  14. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  15. package/build/src/sync/sync-envelope-replay-service.js +103 -10
  16. package/build/src/sync/sync-replay-upsert-applier.d.ts +102 -0
  17. package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -0
  18. package/build/src/sync/sync-replay-upsert-applier.js +158 -0
  19. package/build/src/sync/sync-resource-base.d.ts +48 -0
  20. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  21. package/build/src/sync/sync-resource-base.js +89 -1
  22. package/build/sync/server-sequence-allocator.js +246 -0
  23. package/build/sync/sync-attribute-normalizer.js +186 -0
  24. package/build/sync/sync-envelope-replay-service.js +114 -9
  25. package/build/sync/sync-replay-upsert-applier.js +179 -0
  26. package/build/sync/sync-resource-base.js +103 -0
  27. package/build/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +2 -2
  29. package/src/database/drivers/base.js +6 -0
  30. package/src/database/query/insert-base.js +1 -1
  31. package/src/sync/server-sequence-allocator.js +246 -0
  32. package/src/sync/sync-attribute-normalizer.js +186 -0
  33. package/src/sync/sync-envelope-replay-service.js +114 -9
  34. package/src/sync/sync-replay-upsert-applier.js +179 -0
  35. package/src/sync/sync-resource-base.js +103 -0
@@ -0,0 +1,246 @@
1
+ // @ts-check
2
+
3
+ import Configuration from "../configuration.js"
4
+ import TableData from "../database/table-data/index.js"
5
+
6
+ /**
7
+ * Allocation queues per database+table, serializing insert/last-insert-id
8
+ * pairs across all allocator instances in this process.
9
+ * @type {Map<string, Promise<void>>}
10
+ */
11
+ const allocationQueues = new Map()
12
+
13
+ /**
14
+ * Allocates monotonically increasing server sync sequences from an
15
+ * AUTO_INCREMENT id table: every allocation inserts a row and returns the
16
+ * driver's last-insert id, so sequences stay unique and increasing across
17
+ * processes sharing the same database.
18
+ *
19
+ * Backed by an auto-created `velocious_server_sequences` table on the
20
+ * configured database, with a process-local memory counter fallback when no
21
+ * database is configured (mirroring the sync scope store). Apps with an
22
+ * existing sequence table (for example a bare `id`-only AUTO_INCREMENT table)
23
+ * point `tableName` at it and pass `insertData: {}` to insert empty rows.
24
+ */
25
+ export default class ServerSequenceAllocator {
26
+ /**
27
+ * Creates a server sequence allocator.
28
+ * @param {object} [args] - Options.
29
+ * @param {import("../configuration.js").default} [args.configuration] - Configuration owning the database. Defaults to the current configuration, resolved lazily per allocation.
30
+ * @param {string} [args.databaseIdentifier] - Database identifier.
31
+ * @param {Record<string, ?>} [args.insertData] - Row payload inserted per allocation. Defaults to `{created_at: new Date()}` matching the auto-created table; pass `{}` for bare id-only tables.
32
+ * @param {string} [args.tableName] - Sequence table name.
33
+ */
34
+ constructor({configuration, databaseIdentifier = "default", insertData, tableName = "velocious_server_sequences"} = {}) {
35
+ this.configuration = configuration
36
+ this.databaseIdentifier = databaseIdentifier
37
+ this.insertData = insertData
38
+ this.tableName = tableName
39
+ this._memorySequence = 0
40
+ this._isReady = false
41
+ /** @type {Promise<void> | null} */
42
+ this._readyPromise = null
43
+ }
44
+
45
+ /**
46
+ * Allocates the next monotonically increasing sequence.
47
+ *
48
+ * Allocations serialize through a module-level queue per database+table so
49
+ * parallel `next()` calls - including calls from other allocator instances
50
+ * sharing the same table and connection - cannot interleave their insert
51
+ * and last-insert-id reads and hand out duplicate sequences.
52
+ * @returns {Promise<number>} Next sequence value.
53
+ */
54
+ async next() {
55
+ const queueKey = `${this.databaseIdentifier}::${this.tableName}`
56
+ const previousAllocation = allocationQueues.get(queueKey) ?? Promise.resolve()
57
+ const allocation = previousAllocation.then(() => this._allocateNext())
58
+
59
+ allocationQueues.set(queueKey, allocation.then(() => undefined, () => undefined))
60
+
61
+ return await allocation
62
+ }
63
+
64
+ /**
65
+ * Ensures the backing table exists.
66
+ * @returns {Promise<void>} Resolves when ready.
67
+ */
68
+ async ensureReady() {
69
+ if (this._isReady) return
70
+
71
+ if (this._usesMemoryStorage()) {
72
+ this._isReady = true
73
+ return
74
+ }
75
+
76
+ if (this._readyPromise) return await this._readyPromise
77
+
78
+ this._readyPromise = this._withDb(async (db) => {
79
+ const created = await this._ensureSequencesTable(db)
80
+
81
+ // DDL joins any transaction already open on this connection (the mixin's
82
+ // beforeCreate allocation always runs inside the record save transaction),
83
+ // and on transactional-DDL databases (MSSQL, PostgreSQL, SQLite) a rollback
84
+ // of that outer transaction removes the just-created table again. Only
85
+ // cache readiness when the table was not created inside an active
86
+ // transaction; otherwise the next allocation re-verifies the table.
87
+ if (!created || !db.insideTransaction()) this._isReady = true
88
+ })
89
+
90
+ try {
91
+ await this._readyPromise
92
+ } finally {
93
+ if (!this._isReady) this._readyPromise = null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Allocates one sequence value after queueing.
99
+ * @returns {Promise<number>} Allocated sequence value.
100
+ */
101
+ async _allocateNext() {
102
+ await this.ensureReady()
103
+
104
+ if (this._usesMemoryStorage()) {
105
+ return ++this._memorySequence
106
+ }
107
+
108
+ return await this._withDb(async (db) => {
109
+ // The allocated id must be returned by the insert statement itself (OUTPUT
110
+ // INSERTED/RETURNING), like the record create path does: MSSQL's
111
+ // SCOPE_IDENTITY() only sees inserts from the same batch/scope, so reading
112
+ // the last-insert id as a separate query always returns NULL there. Drivers
113
+ // without insert-returning support (older SQLite) keep the reliable
114
+ // connection-scoped last-insert-id fallback.
115
+ const insertSql = db.insertSql({
116
+ data: this._insertPayload(),
117
+ returnLastInsertedColumnNames: ["id"],
118
+ tableName: this.tableName
119
+ })
120
+ const insertResult = await db.query(insertSql)
121
+ const insertedId = Array.isArray(insertResult) ? insertResult[0]?.id : undefined
122
+
123
+ if (insertedId !== undefined && insertedId !== null) return Number(insertedId)
124
+
125
+ return Number(await db.lastInsertID())
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Builds the row payload inserted per allocation.
131
+ * @returns {Record<string, ?>} Insert payload.
132
+ */
133
+ _insertPayload() {
134
+ return this.insertData ?? {created_at: new Date()}
135
+ }
136
+
137
+ /**
138
+ * Resolves the configuration owning the sequence table.
139
+ * @returns {import("../configuration.js").default} Resolved configuration.
140
+ */
141
+ _getConfiguration() {
142
+ return this.configuration ?? Configuration.current()
143
+ }
144
+
145
+ /**
146
+ * Whether the allocator runs without a configured database.
147
+ * @returns {boolean} Whether memory storage is used.
148
+ */
149
+ _usesMemoryStorage() {
150
+ try {
151
+ return !this._getConfiguration().getDatabaseConfiguration()[this.databaseIdentifier]
152
+ } catch {
153
+ return true
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Runs a callback with a database connection.
159
+ * @template Result
160
+ * @param {(db: import("../database/drivers/base.js").default) => Promise<Result>} callback - Database callback.
161
+ * @returns {Promise<Result>} Callback result.
162
+ */
163
+ async _withDb(callback) {
164
+ return await this._getConfiguration().ensureConnections({name: "Server sequence allocator"}, async (dbs) => {
165
+ const db = dbs[this.databaseIdentifier]
166
+
167
+ if (!db) throw new Error(`No database connection available for identifier: ${this.databaseIdentifier}`)
168
+
169
+ return await callback(db)
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Ensures the sequences table exists.
175
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
176
+ * @returns {Promise<boolean>} Whether the table had to be created.
177
+ */
178
+ async _ensureSequencesTable(db) {
179
+ if (await db.tableExists(this.tableName)) return false
180
+
181
+ const table = new TableData(this.tableName, {ifNotExists: true})
182
+
183
+ table.bigint("id", {autoIncrement: true, null: false, primaryKey: true})
184
+ table.datetime("created_at", {null: false})
185
+
186
+ await db.createTable(table)
187
+
188
+ return true
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Wires server sequencing onto a sync model class: registers a beforeCreate
194
+ * lifecycle callback assigning the next sequence when the record has none, and
195
+ * defines an `advance<Column>()` instance method (when the model does not
196
+ * already define one) that re-sequences the record through the allocator.
197
+ *
198
+ * The sequence is always written through the model's generated typed setter
199
+ * (for example `setServerSequence`), so the model must expose the generated
200
+ * `set<Column>`/`has<Column>` accessors for the column.
201
+ * @template {typeof import("../database/record/index.js").default} TModelClass
202
+ * @param {TModelClass} ModelClass - Sync model class to sequence.
203
+ * @param {object} args - Options.
204
+ * @param {ServerSequenceAllocator} args.allocator - Allocator providing sequence values.
205
+ * @param {string} [args.column] - Sequence attribute name.
206
+ * @returns {TModelClass} The given model class.
207
+ */
208
+ export function withServerSequence(ModelClass, {allocator, column = "serverSequence"}) {
209
+ if (!(allocator instanceof ServerSequenceAllocator)) {
210
+ throw new Error(`withServerSequence requires a ServerSequenceAllocator, got: ${String(allocator)}`)
211
+ }
212
+
213
+ const upperColumn = `${column.charAt(0).toUpperCase()}${column.slice(1)}`
214
+ const advanceMethodName = `advance${upperColumn}`
215
+ const hasMethodName = `has${upperColumn}`
216
+ const setterMethodName = `set${upperColumn}`
217
+
218
+ // Narrows the prototype to dynamic method access for the configured column name.
219
+ const prototype = /** @type {Record<string, ?>} */ (ModelClass.prototype)
220
+
221
+ if (typeof prototype[setterMethodName] != "function" || typeof prototype[hasMethodName] != "function") {
222
+ throw new Error(`withServerSequence requires generated ${setterMethodName} and ${hasMethodName} accessors on ${ModelClass.name}`)
223
+ }
224
+
225
+ if (typeof prototype[advanceMethodName] != "function") {
226
+ /**
227
+ * Assigns the next server-side sequence.
228
+ * @this {Record<string, ?>}
229
+ * @returns {Promise<void>}
230
+ */
231
+ prototype[advanceMethodName] = async function advanceServerSequenceThroughAllocator() {
232
+ this[setterMethodName](await allocator.next())
233
+ }
234
+ }
235
+
236
+ ModelClass.beforeCreate(async (record) => {
237
+ // Narrows the record to dynamic method access for the configured column name.
238
+ const dynamicRecord = /** @type {Record<string, ?>} */ (/** @type {?} */ (record))
239
+
240
+ if (dynamicRecord[hasMethodName]()) return
241
+
242
+ await dynamicRecord[advanceMethodName]()
243
+ })
244
+
245
+ return ModelClass
246
+ }
@@ -0,0 +1,186 @@
1
+ // @ts-check
2
+
3
+ import {snakeCaseKey} from "typanic"
4
+
5
+ /**
6
+ * Builds the error thrown for a failed sync attribute validation.
7
+ * @callback SyncAttributeErrorFactory
8
+ * @param {string} message - Human-readable validation message.
9
+ * @param {{cause?: Error, code: string}} details - Stable machine-readable code and optional cause.
10
+ * @returns {Error} Error to throw.
11
+ */
12
+
13
+ /**
14
+ * Declarative validation schema for one writable sync attribute.
15
+ *
16
+ * Keys are camelCase attribute names; each attribute also accepts its
17
+ * snake_case column key on input (the camelCase key wins when both are given)
18
+ * and is written under the snake_case column key on output. Error messages
19
+ * default to `<label> ...` phrasings with `sync-<label>-...` codes, where the
20
+ * label defaults to the camelCase attribute name; apps pin exact messages per
21
+ * attribute through the `*Message` overrides.
22
+ * @typedef {object} SyncAttributeSchemaEntry
23
+ * @property {"date" | "json" | "raw" | "string"} type - Attribute value type.
24
+ * @property {boolean} [required] - Whether present blank/absent values are rejected (strings reject blank-after-trim, dates reject null).
25
+ * @property {number} [maxLength] - Maximum string length, checked after trimming.
26
+ * @property {string} [label] - Label used in derived messages and codes.
27
+ * @property {string} [column] - Output column key. Defaults to the snake_cased attribute name.
28
+ * @property {string} [requiredMessage] - Pinned message for required rejections.
29
+ * @property {string} [tooLongMessage] - Pinned message for too-long rejections.
30
+ * @property {string} [invalidMessage] - Pinned message for invalid dates and non-object/non-string json values.
31
+ * @property {string} [invalidJsonMessage] - Pinned message for unparseable json strings.
32
+ */
33
+
34
+ /**
35
+ * Normalizes writable sync attributes against a declarative schema.
36
+ *
37
+ * Only present keys are validated, so required attributes may be absent —
38
+ * create defaults own that concern. Input keys not declared in the schema are
39
+ * rejected by default so misspelled or read-only attributes fail loudly
40
+ * instead of being silently discarded (`unknownAttributes: "ignore"` opts
41
+ * out). Validation failures throw through the given error factory so apps
42
+ * control the raised error class.
43
+ * @param {object} args - Options.
44
+ * @param {Record<string, ?>} args.attributes - Raw incoming attributes (camelCase and/or snake_case keys).
45
+ * @param {SyncAttributeErrorFactory} args.errorFactory - Maps validation failures to thrown errors.
46
+ * @param {Record<string, SyncAttributeSchemaEntry>} args.schema - Attribute schema keyed by camelCase attribute name.
47
+ * @param {"error" | "ignore"} [args.unknownAttributes] - Unknown input-key handling. Defaults to "error".
48
+ * @returns {Record<string, ?>} Normalized values keyed by snake_case column names.
49
+ */
50
+ export default function normalizeAttributesWithSchema({attributes, errorFactory, schema, unknownAttributes = "error"}) {
51
+ if (!attributes || typeof attributes != "object") throw new Error(`normalizeAttributesWithSchema requires an attributes object, got: ${String(attributes)}`)
52
+ if (typeof errorFactory != "function") throw new Error("normalizeAttributesWithSchema requires an errorFactory function")
53
+ if (!schema || typeof schema != "object") throw new Error(`normalizeAttributesWithSchema requires a schema object, got: ${String(schema)}`)
54
+ if (unknownAttributes != "error" && unknownAttributes != "ignore") {
55
+ throw new Error(`normalizeAttributesWithSchema unknownAttributes must be "error" or "ignore", got: ${String(unknownAttributes)}`)
56
+ }
57
+
58
+ /** @type {Record<string, ?>} */
59
+ const normalized = {}
60
+ /** @type {Set<string>} */
61
+ const knownInputKeys = new Set()
62
+
63
+ for (const [attributeName, entry] of Object.entries(schema)) {
64
+ const column = entry.column ?? snakeCaseKey(attributeName)
65
+ const inputKeys = column == attributeName ? [attributeName] : [column, attributeName]
66
+
67
+ for (const inputKey of inputKeys) {
68
+ knownInputKeys.add(inputKey)
69
+
70
+ if (!Object.prototype.hasOwnProperty.call(attributes, inputKey)) continue
71
+
72
+ normalized[column] = normalizeAttributeValue({attributeName, entry, errorFactory, value: attributes[inputKey]})
73
+ }
74
+ }
75
+
76
+ if (unknownAttributes == "error") {
77
+ for (const inputKey of Object.keys(attributes)) {
78
+ if (!knownInputKeys.has(inputKey)) {
79
+ throw errorFactory(`Unknown attribute: ${inputKey}.`, {code: "sync-unknown-attribute"})
80
+ }
81
+ }
82
+ }
83
+
84
+ return normalized
85
+ }
86
+
87
+ /**
88
+ * Normalizes one present attribute value per its schema entry.
89
+ * @param {object} args - Options.
90
+ * @param {string} args.attributeName - camelCase attribute name.
91
+ * @param {SyncAttributeSchemaEntry} args.entry - Schema entry.
92
+ * @param {SyncAttributeErrorFactory} args.errorFactory - Error factory.
93
+ * @param {?} args.value - Raw value.
94
+ * @returns {?} Normalized value.
95
+ */
96
+ function normalizeAttributeValue({attributeName, entry, errorFactory, value}) {
97
+ const label = entry.label ?? attributeName
98
+
99
+ if (entry.type == "string") return normalizeString({entry, errorFactory, label, value})
100
+ if (entry.type == "date") return normalizeDate({entry, errorFactory, label, value})
101
+ if (entry.type == "json") return normalizeJson({entry, errorFactory, label, value})
102
+ if (entry.type == "raw") return value
103
+
104
+ throw new Error(`Unknown sync attribute schema type for ${attributeName}: ${String(entry.type)}`)
105
+ }
106
+
107
+ /**
108
+ * Normalizes a string attribute: trims, rejects blank required values after trimming, maps empty optional values to null and bounds the trimmed length.
109
+ * @param {object} args - Options.
110
+ * @param {SyncAttributeSchemaEntry} args.entry - Schema entry.
111
+ * @param {SyncAttributeErrorFactory} args.errorFactory - Error factory.
112
+ * @param {string} args.label - Message/code label.
113
+ * @param {?} args.value - Raw value.
114
+ * @returns {string | null} Normalized string.
115
+ */
116
+ function normalizeString({entry, errorFactory, label, value}) {
117
+ const requiredError = () => errorFactory(entry.requiredMessage ?? `${label} is required.`, {code: `sync-${label}-required`})
118
+
119
+ if (value === null || value === undefined || value === "") {
120
+ if (entry.required) throw requiredError()
121
+
122
+ return null
123
+ }
124
+
125
+ const stringValue = String(value).trim()
126
+
127
+ if (stringValue === "") {
128
+ if (entry.required) throw requiredError()
129
+
130
+ return null
131
+ }
132
+
133
+ if (entry.maxLength !== undefined && stringValue.length > entry.maxLength) {
134
+ throw errorFactory(entry.tooLongMessage ?? `${label} is too long (maximum is ${entry.maxLength} characters).`, {code: `sync-${label}-too-long`})
135
+ }
136
+
137
+ return stringValue
138
+ }
139
+
140
+ /**
141
+ * Normalizes a date attribute: Date instances pass through, strings/numbers are parsed and invalid or missing-required values are rejected.
142
+ * @param {object} args - Options.
143
+ * @param {SyncAttributeSchemaEntry} args.entry - Schema entry.
144
+ * @param {SyncAttributeErrorFactory} args.errorFactory - Error factory.
145
+ * @param {string} args.label - Message/code label.
146
+ * @param {?} args.value - Raw value.
147
+ * @returns {Date | null} Normalized date.
148
+ */
149
+ function normalizeDate({entry, errorFactory, label, value}) {
150
+ if (!entry.required && (value === null || value === undefined || value === "")) return null
151
+
152
+ const dateValue = value instanceof Date ? value : typeof value == "string" || typeof value == "number" ? new Date(value) : null
153
+
154
+ if (!dateValue || Number.isNaN(dateValue.getTime())) {
155
+ throw errorFactory(entry.invalidMessage ?? `${label} must be a valid datetime.`, {code: `sync-${label}-invalid-datetime`})
156
+ }
157
+
158
+ return dateValue
159
+ }
160
+
161
+ /**
162
+ * Normalizes a json attribute: empty values become null, objects pass through, strings must parse as JSON and other types are rejected.
163
+ * @param {object} args - Options.
164
+ * @param {SyncAttributeSchemaEntry} args.entry - Schema entry.
165
+ * @param {SyncAttributeErrorFactory} args.errorFactory - Error factory.
166
+ * @param {string} args.label - Message/code label.
167
+ * @param {?} args.value - Raw value.
168
+ * @returns {?} Normalized json value.
169
+ */
170
+ function normalizeJson({entry, errorFactory, label, value}) {
171
+ if (value === null || value === undefined || value === "") return null
172
+ if (typeof value == "object") return value
173
+
174
+ if (typeof value == "string") {
175
+ try {
176
+ return JSON.parse(value)
177
+ } catch (error) {
178
+ throw errorFactory(entry.invalidJsonMessage ?? `${label} must be valid JSON.`, {
179
+ cause: /** @type {Error} */ (error),
180
+ code: `sync-${label}-invalid-json`
181
+ })
182
+ }
183
+ }
184
+
185
+ throw errorFactory(entry.invalidMessage ?? `${label} must be an object or JSON string.`, {code: `sync-${label}-invalid`})
186
+ }
@@ -1,5 +1,16 @@
1
1
  // @ts-check
2
2
 
3
+ import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
4
+
5
+ /**
6
+ * One declarative broadcast fanned out after a mutation applies.
7
+ * @typedef {object} SyncReplayBroadcast
8
+ * @property {string | ((args: Record<string, ?>) => string)} channel - Channel name or resolver.
9
+ * @property {(args: Record<string, ?>) => Record<string, ?>} broadcastParams - Channel routing params.
10
+ * @property {(args: Record<string, ?>) => ?} body - Broadcast body.
11
+ * @property {(args: Record<string, ?>) => boolean} [when] - Optional gate; skipped when it returns false.
12
+ */
13
+
3
14
  /**
4
15
  * Replays client sync envelopes through project supplied authentication,
5
16
  * authorization, application, and persistence hooks.
@@ -23,15 +34,49 @@ export default class SyncEnvelopeReplayService {
23
34
  * @param {{debug?: (...args: Array<unknown>) => void, warn?: (...args: Array<unknown>) => void}} [args.logger] - Logger used for normalization warnings.
24
35
  * @param {?} [args.syncModel] - Sync/change model enabling model-backed default hooks.
25
36
  * @param {string} [args.actorForeignKeyColumn] - Sync model column linking rows to the replay actor.
37
+ * @param {?} [args.authenticationTokenModel] - Token model enabling the default token-lookup authenticateReplay.
38
+ * @param {string} [args.authenticationTokenColumn] - Token model column holding the token. Defaults to "token".
39
+ * @param {string} [args.authenticationTokenParam] - Request param carrying the token. Defaults to "authenticationToken".
40
+ * @param {Record<string, ((args: Record<string, ?>) => Promise<?>) | ConstructorParameters<typeof SyncReplayUpsertApplier>[0]>} [args.applyHandlers] - Per-resourceType apply handlers (functions or declarative upsert-applier specs) enabling the default applyReplayMutation dispatch.
41
+ * @param {(args: Record<string, ?>) => Record<string, ?>} [args.persistExtraAttributes] - Extra attributes merged into the model-backed persisted row (e.g. an event scope column).
42
+ * @param {(args: {mutation: ?, applyResult: ?}) => ?} [args.persistSerializedData] - Overrides the persisted data payload (object results are JSON stringified).
43
+ * @param {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [args.broadcaster] - Delivers declarative broadcasts. Required when broadcasts are configured.
44
+ * @param {SyncReplayBroadcast[]} [args.broadcasts] - Broadcasts fanned out by the default afterReplayMutation.
26
45
  */
27
46
  constructor(args = {}) {
28
47
  this.logger = args.logger || console
29
48
  this.syncModel = args.syncModel || null
30
49
  this.actorForeignKeyColumn = args.actorForeignKeyColumn || "authentication_token_id"
50
+ this.authenticationTokenModel = args.authenticationTokenModel || null
51
+ this.authenticationTokenColumn = args.authenticationTokenColumn || "token"
52
+ this.authenticationTokenParam = args.authenticationTokenParam || "authenticationToken"
53
+ this.persistExtraAttributes = args.persistExtraAttributes || null
54
+ this.persistSerializedData = args.persistSerializedData || null
55
+ this.broadcaster = args.broadcaster || null
56
+ this.broadcasts = args.broadcasts || null
57
+ this.applyHandlers = args.applyHandlers ? this.builtApplyHandlers(args.applyHandlers) : null
31
58
 
32
59
  if (args.actorForeignKeyColumn !== undefined && (typeof args.actorForeignKeyColumn !== "string" || args.actorForeignKeyColumn.length < 1)) {
33
60
  throw new Error(`actorForeignKeyColumn must be a non-blank string, got: ${String(args.actorForeignKeyColumn)}`)
34
61
  }
62
+ if (this.broadcasts && !this.broadcaster) {
63
+ throw new Error("SyncEnvelopeReplayService broadcasts require a broadcaster option delivering them")
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Wraps declarative apply-handler specs in upsert appliers.
69
+ * @param {Record<string, ?>} applyHandlers - Raw apply handlers.
70
+ * @returns {Record<string, (args: Record<string, ?>) => Promise<?>>} Callable handlers by resource type.
71
+ */
72
+ builtApplyHandlers(applyHandlers) {
73
+ return Object.fromEntries(Object.entries(applyHandlers).map(([resourceType, handler]) => {
74
+ if (typeof handler === "function") return [resourceType, handler]
75
+
76
+ const applier = new SyncReplayUpsertApplier(handler)
77
+
78
+ return [resourceType, (/** @type {Record<string, ?>} */ applyArgs) => applier.apply(/** @type {?} */ (applyArgs))]
79
+ }))
35
80
  }
36
81
 
37
82
  /**
@@ -91,11 +136,30 @@ export default class SyncEnvelopeReplayService {
91
136
 
92
137
  /**
93
138
  * Authenticates the sync batch actor.
94
- * @param {Record<string, ?>} _params - Request params.
139
+ *
140
+ * Defaults to a token-model lookup when `authenticationTokenModel` is
141
+ * configured; otherwise apps override this hook.
142
+ * @param {Record<string, ?>} params - Request params.
95
143
  * @returns {Promise<{authenticated: true, actor: ?} | {authenticated: false, errorCode: string, errorMessage: string}>} Auth result.
96
144
  */
97
- async authenticateReplay(_params) {
98
- throw new Error("SyncEnvelopeReplayService.authenticateReplay must be implemented")
145
+ async authenticateReplay(params) {
146
+ if (!this.authenticationTokenModel) {
147
+ throw new Error("SyncEnvelopeReplayService.authenticateReplay must be implemented (or configure authenticationTokenModel)")
148
+ }
149
+
150
+ const token = params[this.authenticationTokenParam]
151
+
152
+ if (!token) {
153
+ return {authenticated: false, errorCode: "missing-authentication-token", errorMessage: "Missing authentication token"}
154
+ }
155
+
156
+ const actor = await this.authenticationTokenModel.findBy({[this.authenticationTokenColumn]: token})
157
+
158
+ if (!actor) {
159
+ return {authenticated: false, errorCode: "invalid-authentication-token", errorMessage: "Invalid authentication token"}
160
+ }
161
+
162
+ return {actor, authenticated: true}
99
163
  }
100
164
 
101
165
  /**
@@ -257,11 +321,20 @@ export default class SyncEnvelopeReplayService {
257
321
 
258
322
  /**
259
323
  * Applies one normalized mutation to domain models.
260
- * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} _args - Actor, batch context, existing sync row, and mutation.
324
+ *
325
+ * Defaults to dispatching through the configured apply-handler registry;
326
+ * mutations without a registered handler fail loudly.
327
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
261
328
  * @returns {Promise<?>} Project-specific apply result.
262
329
  */
263
- async applyReplayMutation(_args) {
264
- return null
330
+ async applyReplayMutation(args) {
331
+ if (!this.applyHandlers) return null
332
+
333
+ const applyHandler = this.applyHandlers[args.mutation.resourceType]
334
+
335
+ if (!applyHandler) throw new Error(`No sync apply handler registered for: ${args.mutation.resourceType}`)
336
+
337
+ return await applyHandler(args)
265
338
  }
266
339
 
267
340
  /**
@@ -281,11 +354,25 @@ export default class SyncEnvelopeReplayService {
281
354
  * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} args - Replay persistence arguments.
282
355
  * @returns {Promise<void>}
283
356
  */
284
- async persistReplayMutation({actor, existingSync, mutation}) {
357
+ async persistReplayMutation({actor, applyResult, context, existingSync, mutation, shouldApply}) {
285
358
  if (!this.syncModel) return
286
359
 
287
360
  const attributes = this.replayPersistAttributes({actor, mutation})
288
361
 
362
+ // Stale replays never applied anything, so the applyResult-driven extension
363
+ // hooks must not run against the default null skipped result.
364
+ if (this.persistExtraAttributes && shouldApply) {
365
+ Object.assign(attributes, this.persistExtraAttributes({actor, applyResult, context, existingSync, mutation, shouldApply}))
366
+ }
367
+
368
+ if (this.persistSerializedData && shouldApply) {
369
+ const serializedData = this.persistSerializedData({applyResult, mutation})
370
+
371
+ if (serializedData !== undefined && serializedData !== null) {
372
+ attributes.data = typeof serializedData === "string" ? serializedData : JSON.stringify(serializedData)
373
+ }
374
+ }
375
+
289
376
  if (existingSync) {
290
377
  const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
291
378
 
@@ -317,10 +404,28 @@ export default class SyncEnvelopeReplayService {
317
404
 
318
405
  /**
319
406
  * Runs side effects after a successful mutation replay and persistence.
320
- * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} _args - Replay side-effect arguments.
407
+ *
408
+ * Defaults to fanning the applied result out through the configured
409
+ * declarative broadcasts.
410
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} args - Replay side-effect arguments.
321
411
  * @returns {Promise<void>}
322
412
  */
323
- async afterReplayMutation(_args) {}
413
+ async afterReplayMutation(args) {
414
+ if (!this.broadcasts || !this.broadcaster) return
415
+ // Stale replays never applied anything - broadcasting their skipped results
416
+ // would fan out stale side effects (or crash on the default null applyResult).
417
+ if (!args.shouldApply) return
418
+
419
+ for (const broadcast of this.broadcasts) {
420
+ if (broadcast.when && !broadcast.when(args)) continue
421
+
422
+ await this.broadcaster({
423
+ body: broadcast.body(args),
424
+ channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
425
+ params: broadcast.broadcastParams(args)
426
+ })
427
+ }
428
+ }
324
429
  }
325
430
 
326
431
  /**