velocious 1.0.669 → 1.0.671

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.
@@ -0,0 +1,158 @@
1
+ // @ts-check
2
+
3
+ import restArgsError from "../utils/rest-args-error.js"
4
+ import sha256Hex from "../utils/sha256-hex.js"
5
+ import stableJsonStringify from "../utils/stable-json.js"
6
+
7
+ import {upsertSyncRow} from "./sync-change-fanout.js"
8
+ import {declaredSyncScopeAttributes} from "./sync-scope-attributes.js"
9
+
10
+ /**
11
+ * Atomically reconciles and upserts one server-origin sync row by its complete,
12
+ * null-safe logical identity. The identity contains the null actor, resource
13
+ * type/id, and either every scope column declared by the sync model or the
14
+ * caller's explicit effective scope columns for a supported legacy publisher.
15
+ * A stable portable advisory lock serializes runtime publication and maintenance/backfill calls;
16
+ * legacy duplicates converge to the newest server sequence, then the lowest
17
+ * immutable id, before the survivor is updated and re-sequenced.
18
+ *
19
+ * The lock is acquired through the static sync model on a dedicated connection.
20
+ * `persistenceModel` may be an operation-bound model facade so reads/writes keep
21
+ * their owning operation while sharing the same identity lock as ordinary
22
+ * publisher calls.
23
+ * @param {object} args - Persistence arguments.
24
+ * @param {string} [args.actorForeignKeyColumn] - Persisted actor foreign-key column.
25
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} args.attributes - Complete sync-row mutation attributes.
26
+ * @param {ReturnType<typeof JSON.parse>} [args.persistenceModel] - Optional operation-bound model used for row reads/writes.
27
+ * @param {string[]} [args.scopeColumnNames] - Exact persisted scope columns overriding model declarations for a supported legacy publisher identity.
28
+ * @param {ReturnType<typeof JSON.parse>} args.syncModel - Static sync model owning scope metadata and the advisory lock.
29
+ * @returns {Promise<ReturnType<typeof JSON.parse>>} Created or reconciled sync row.
30
+ */
31
+ export async function upsertServerOriginSyncRow({
32
+ actorForeignKeyColumn = "authentication_token_id",
33
+ attributes,
34
+ persistenceModel,
35
+ scopeColumnNames,
36
+ syncModel,
37
+ ...restArgs
38
+ }) {
39
+ restArgsError(restArgs)
40
+
41
+ if (typeof actorForeignKeyColumn !== "string" || !actorForeignKeyColumn) {
42
+ throw new Error("Server-origin sync row actorForeignKeyColumn must be a non-empty string")
43
+ }
44
+ if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) {
45
+ throw new Error("Server-origin sync row attributes must be a plain object")
46
+ }
47
+ if (!Object.hasOwn(attributes, actorForeignKeyColumn) || attributes[actorForeignKeyColumn] === undefined) {
48
+ throw new Error(`Server-origin sync row identity must include the actor column ${actorForeignKeyColumn}`)
49
+ }
50
+ if (attributes[actorForeignKeyColumn] !== null) {
51
+ throw new Error(`Server-origin sync row actor column ${actorForeignKeyColumn} must be null`)
52
+ }
53
+
54
+ validateRequiredIdentityString({attributes, columnName: "resource_id"})
55
+ validateRequiredIdentityString({attributes, columnName: "resource_type"})
56
+
57
+ /** @type {Record<string, ReturnType<typeof JSON.parse>>} */
58
+ const identity = {
59
+ [actorForeignKeyColumn]: null,
60
+ resource_id: attributes.resource_id,
61
+ resource_type: attributes.resource_type
62
+ }
63
+ const attributeToColumnName = syncModel.getAttributeNameToColumnNameMap()
64
+ const scopeColumns = new Set(scopeColumnNames || [])
65
+
66
+ if (scopeColumnNames === undefined) {
67
+ for (const scopeAttribute of declaredSyncScopeAttributes(syncModel) || []) {
68
+ const columnName = attributeToColumnName[scopeAttribute]
69
+
70
+ if (!columnName) {
71
+ throw new Error(`${syncModel.name} declares the sync scope attribute ${scopeAttribute} but has no matching column for it`)
72
+ }
73
+
74
+ scopeColumns.add(columnName)
75
+ }
76
+ }
77
+
78
+ for (const columnName of scopeColumns) {
79
+ if (typeof columnName !== "string" || !columnName || !Object.values(attributeToColumnName).includes(columnName)) {
80
+ throw new Error(`Server-origin sync row received an unknown scope column: ${String(columnName)}`)
81
+ }
82
+
83
+ if (!Object.hasOwn(attributes, columnName) || attributes[columnName] === undefined) {
84
+ throw new Error(`Server-origin sync row identity must include the declared scope column ${columnName}`)
85
+ }
86
+
87
+ const value = attributes[columnName]
88
+
89
+ if (value !== null && typeof value !== "string" && (typeof value !== "number" || !Number.isFinite(value))) {
90
+ throw new Error(`Server-origin sync row scope column ${columnName} must be a string, finite number, or null`)
91
+ }
92
+
93
+ identity[columnName] = value
94
+ }
95
+
96
+ const rowModel = persistenceModel || syncModel
97
+
98
+ return await syncModel.withAdvisoryLock(serverOriginSyncRowLockName(identity), async () => {
99
+ const matchingSyncs = await rowModel
100
+ .where(identity)
101
+ .toArray()
102
+
103
+ matchingSyncs.sort(compareServerOriginSyncRowsByRecency)
104
+
105
+ const [existingSync, ...duplicateSyncs] = matchingSyncs
106
+
107
+ for (const duplicateSync of duplicateSyncs) {
108
+ await duplicateSync.destroy()
109
+ }
110
+
111
+ return await upsertSyncRow({attributes, existingSync, syncModel: rowModel})
112
+ }, {dedicatedConnection: true})
113
+ }
114
+
115
+ /**
116
+ * Validates one required string component of the server-origin identity.
117
+ * @param {{attributes: Record<string, ReturnType<typeof JSON.parse>>, columnName: "resource_id" | "resource_type"}} args - Attributes and required column.
118
+ * @returns {void}
119
+ */
120
+ function validateRequiredIdentityString({attributes, columnName}) {
121
+ const value = attributes[columnName]
122
+
123
+ if (typeof value !== "string" || !value) {
124
+ throw new Error(`Server-origin sync row ${columnName} must be a non-empty string`)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Returns the stable MySQL-safe advisory-lock name shared by every caller of a
130
+ * complete server-origin identity. Stable JSON preserves null identity values,
131
+ * while the truncated SHA-256 digest keeps the name under GET_LOCK's limit.
132
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} identity - Complete sync-row identity in column form.
133
+ * @returns {string} Advisory-lock name.
134
+ */
135
+ function serverOriginSyncRowLockName(identity) {
136
+ const hash = sha256Hex(stableJsonStringify(identity)).slice(0, 32)
137
+
138
+ return `vsp:${hash}`
139
+ }
140
+
141
+ /**
142
+ * Orders matching rows with the canonical deterministic survivor first:
143
+ * newest server sequence, then lowest immutable id. A null legacy sequence is
144
+ * older than every assigned sequence.
145
+ * @param {ReturnType<typeof JSON.parse>} left - First matching sync row.
146
+ * @param {ReturnType<typeof JSON.parse>} right - Second matching sync row.
147
+ * @returns {number} Sort comparison.
148
+ */
149
+ function compareServerOriginSyncRowsByRecency(left, right) {
150
+ const leftSequence = left.serverSequence()
151
+ const rightSequence = right.serverSequence()
152
+
153
+ if (leftSequence === null && rightSequence !== null) return 1
154
+ if (leftSequence !== null && rightSequence === null) return -1
155
+ if (leftSequence !== rightSequence) return rightSequence - leftSequence
156
+
157
+ return left.id().localeCompare(right.id())
158
+ }