velocious 1.0.486 → 1.0.488

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 (48) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +19 -1
  3. package/build/configuration.js +25 -2
  4. package/build/environment-handlers/base.js +9 -0
  5. package/build/environment-handlers/node.js +105 -19
  6. package/build/packages/velocious-package.js +115 -0
  7. package/build/src/configuration-types.d.ts +57 -2
  8. package/build/src/configuration-types.d.ts.map +1 -1
  9. package/build/src/configuration-types.js +18 -2
  10. package/build/src/configuration.d.ts +9 -1
  11. package/build/src/configuration.d.ts.map +1 -1
  12. package/build/src/configuration.js +21 -3
  13. package/build/src/environment-handlers/base.d.ts +6 -0
  14. package/build/src/environment-handlers/base.d.ts.map +1 -1
  15. package/build/src/environment-handlers/base.js +9 -1
  16. package/build/src/environment-handlers/node.d.ts +18 -0
  17. package/build/src/environment-handlers/node.d.ts.map +1 -1
  18. package/build/src/environment-handlers/node.js +94 -19
  19. package/build/src/packages/velocious-package.d.ts +69 -0
  20. package/build/src/packages/velocious-package.d.ts.map +1 -0
  21. package/build/src/packages/velocious-package.js +101 -0
  22. package/build/src/sync/sync-api-client-types.d.ts +182 -0
  23. package/build/src/sync/sync-api-client-types.d.ts.map +1 -0
  24. package/build/src/sync/sync-api-client-types.js +3 -0
  25. package/build/src/sync/sync-api-client.d.ts +188 -0
  26. package/build/src/sync/sync-api-client.d.ts.map +1 -0
  27. package/build/src/sync/sync-api-client.js +307 -0
  28. package/build/src/sync/sync-api-controller.d.ts +78 -0
  29. package/build/src/sync/sync-api-controller.d.ts.map +1 -0
  30. package/build/src/sync/sync-api-controller.js +142 -0
  31. package/build/src/sync/sync-model-change-feed-service.d.ts +131 -0
  32. package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -0
  33. package/build/src/sync/sync-model-change-feed-service.js +212 -0
  34. package/build/sync/sync-api-client-types.js +77 -0
  35. package/build/sync/sync-api-client.js +342 -0
  36. package/build/sync/sync-api-controller.js +161 -0
  37. package/build/sync/sync-model-change-feed-service.js +244 -0
  38. package/build/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -4
  40. package/src/configuration-types.js +19 -1
  41. package/src/configuration.js +25 -2
  42. package/src/environment-handlers/base.js +9 -0
  43. package/src/environment-handlers/node.js +105 -19
  44. package/src/packages/velocious-package.js +115 -0
  45. package/src/sync/sync-api-client-types.js +77 -0
  46. package/src/sync/sync-api-client.js +342 -0
  47. package/src/sync/sync-api-controller.js +161 -0
  48. package/src/sync/sync-model-change-feed-service.js +244 -0
@@ -0,0 +1,244 @@
1
+ // @ts-check
2
+
3
+ import VelociousError from "../velocious-error.js"
4
+
5
+ /**
6
+ * Generic cursor-paginated change feed over an app-owned sync/change model.
7
+ *
8
+ * Apps provide the model and optional scoping/serialization hooks. Velocious owns
9
+ * cursor parsing, snapshot high-water resolution, stable ordering, page limits,
10
+ * and response shape for `/velocious/sync/changes` style endpoints.
11
+ */
12
+ export default class SyncModelChangeFeedService {
13
+ /**
14
+ * Creates a generic sync-model change-feed service.
15
+ * @param {object} args - Service arguments.
16
+ * @param {typeof import("../database/record/index.js").default} args.modelClass - Sync/change model class.
17
+ * @param {Record<string, unknown>} args.params - Request params.
18
+ * @param {number} [args.defaultLimit] - Default page size.
19
+ * @param {number} [args.maxLimit] - Maximum page size.
20
+ * @param {(record: ?) => Record<string, unknown>} [args.serializeRecord] - Record serializer.
21
+ * @param {function({query: import("../database/query/model-class-query.js").default}): void} [args.scopeQuery] - Applies app-owned visibility scope.
22
+ */
23
+ constructor({defaultLimit = 1000, maxLimit = 1000, modelClass, params, scopeQuery, serializeRecord}) {
24
+ this.defaultLimit = defaultLimit
25
+ this.maxLimit = maxLimit
26
+ this.modelClass = modelClass
27
+ this.params = params
28
+ this.scopeQuery = scopeQuery || null
29
+ this.serializeRecord = serializeRecord || ((record) => this.defaultSerializeRecord(record))
30
+ }
31
+
32
+ /**
33
+ * Builds a stable change-feed page.
34
+ * @returns {Promise<{status: string, nextCursor: {id: string, serverSequence: number, updatedAt: string} | null, syncs: Array<Record<string, unknown>>, upToCursor: {id: string, serverSequence: number, updatedAt: string} | null}>} Change-feed page result.
35
+ */
36
+ async changes() {
37
+ const limit = this.normalizedLimit(this.params.limit)
38
+ const upToCursor = await this.resolveUpToCursor()
39
+
40
+ if (!upToCursor) return {status: "success", nextCursor: null, syncs: [], upToCursor: null}
41
+
42
+ const query = this.pageQuery({limit, upToCursor})
43
+ const records = await query.toArray()
44
+ const nextCursor = records.length > 0 ? this.cursorForRecord(records[records.length - 1]) : upToCursor
45
+
46
+ return {
47
+ status: "success",
48
+ nextCursor,
49
+ syncs: records.map((record) => this.serializeRecord(record)),
50
+ upToCursor
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Normalizes the requested page limit.
56
+ * @param {unknown} value - Raw limit param.
57
+ * @returns {number} Normalized page limit.
58
+ */
59
+ normalizedLimit(value) {
60
+ if (value === undefined || value === null || value === "") return this.defaultLimit
61
+
62
+ const limit = Number(value)
63
+
64
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
65
+ throw VelociousError.safe("Sync changes limit must be a positive integer.", {code: "sync-invalid-changes-limit"})
66
+ }
67
+
68
+ return Math.min(limit, this.maxLimit)
69
+ }
70
+
71
+ /**
72
+ * Parses an optional positive integer cursor field.
73
+ * @param {unknown} value - Raw integer param.
74
+ * @param {string} name - Param name for error messages.
75
+ * @returns {number | null} Positive integer value, or null when omitted.
76
+ */
77
+ optionalPositiveIntegerParam(value, name) {
78
+ if (value === undefined || value === null || value === "") return null
79
+
80
+ const integer = Number(value)
81
+
82
+ if (!Number.isSafeInteger(integer) || integer <= 0) {
83
+ throw VelociousError.safe(`${name} must be a positive integer.`, {code: "sync-invalid-changes-cursor"})
84
+ }
85
+
86
+ return integer
87
+ }
88
+
89
+ /**
90
+ * Resolves the high-water cursor that bounds the current feed page.
91
+ * @returns {Promise<{id: string, serverSequence: number, updatedAt: string} | null>} Snapshot upper-bound cursor.
92
+ */
93
+ async resolveUpToCursor() {
94
+ const upToServerSequence = this.optionalPositiveIntegerParam(this.params.upToServerSequence, "upToServerSequence")
95
+
96
+ if (upToServerSequence !== null && typeof this.params.upToUpdatedAt === "string" && typeof this.params.upToId === "string") {
97
+ return {id: this.params.upToId, serverSequence: upToServerSequence, updatedAt: this.params.upToUpdatedAt}
98
+ }
99
+
100
+ if (typeof this.params.upToUpdatedAt === "string" && typeof this.params.upToId === "string") {
101
+ const upToRecord = await this.modelClass.findBy({id: this.params.upToId})
102
+
103
+ if (upToRecord) return this.cursorForRecord(upToRecord)
104
+ }
105
+
106
+ const query = this.scopedQuery()
107
+ const table = query.driver.quoteTable(this.modelClass.tableName())
108
+ const serverSequenceColumn = query.driver.quoteColumn("server_sequence")
109
+ const latestRecords = await query
110
+ .order(`${table}.${serverSequenceColumn} DESC`)
111
+ .limit(1)
112
+ .toArray()
113
+
114
+ if (latestRecords.length === 0) return null
115
+
116
+ return this.cursorForRecord(latestRecords[0])
117
+ }
118
+
119
+ /**
120
+ * Builds the ordered and cursor-filtered page query.
121
+ * @param {{limit: number, upToCursor: {id: string, serverSequence: number, updatedAt: string}}} args - Page query args.
122
+ * @returns {import("../database/query/model-class-query.js").default} Page query.
123
+ */
124
+ pageQuery({limit, upToCursor}) {
125
+ const query = this.scopedQuery()
126
+ const driver = query.driver
127
+ const table = driver.quoteTable(this.modelClass.tableName())
128
+ const serverSequenceColumn = `${table}.${driver.quoteColumn("server_sequence")}`
129
+ const updatedAtColumn = `${table}.${driver.quoteColumn("updated_at")}`
130
+ const idColumn = `${table}.${driver.quoteColumn("id")}`
131
+
132
+ query
133
+ .where(`${serverSequenceColumn} <= ${driver.quote(upToCursor.serverSequence)}`)
134
+ .order(`${serverSequenceColumn} ASC`)
135
+ .limit(limit)
136
+
137
+ const afterServerSequence = this.optionalPositiveIntegerParam(this.params.afterServerSequence, "afterServerSequence")
138
+
139
+ if (afterServerSequence !== null) {
140
+ query.where(`${serverSequenceColumn} > ${driver.quote(afterServerSequence)}`)
141
+ } else if (typeof this.params.afterUpdatedAt === "string" && this.params.afterUpdatedAt !== "") {
142
+ const isPagingExistingSnapshot = typeof this.params.upToUpdatedAt === "string" && this.params.upToUpdatedAt !== "" && typeof this.params.upToId === "string" && this.params.upToId !== ""
143
+
144
+ if (isPagingExistingSnapshot && typeof this.params.afterId === "string" && this.params.afterId !== "") {
145
+ query.where(`(${updatedAtColumn} > ${driver.quote(this.params.afterUpdatedAt)} OR (${updatedAtColumn} = ${driver.quote(this.params.afterUpdatedAt)} AND ${idColumn} > ${driver.quote(this.params.afterId)}))`)
146
+ } else {
147
+ query.where(`${updatedAtColumn} >= ${driver.quote(this.params.afterUpdatedAt)}`)
148
+ }
149
+ }
150
+
151
+ return query
152
+ }
153
+
154
+ /**
155
+ * Builds a base query with app-owned scope applied.
156
+ * @returns {import("../database/query/model-class-query.js").default} Scoped base query.
157
+ */
158
+ scopedQuery() {
159
+ const query = this.modelClass.where({})
160
+
161
+ if (this.scopeQuery) this.scopeQuery({query})
162
+
163
+ return query
164
+ }
165
+
166
+ /**
167
+ * Serializes a record into a transport cursor.
168
+ * @param {?} record - Sync/change record.
169
+ * @returns {{id: string, serverSequence: number, updatedAt: string}} Cursor for row.
170
+ */
171
+ cursorForRecord(record) {
172
+ return {id: String(this.recordValue(record, "id")), serverSequence: Number(this.recordValue(record, "serverSequence")), updatedAt: this.isoDate(this.recordValue(record, "updatedAt"))}
173
+ }
174
+
175
+ /**
176
+ * Serializes a record using the standard sync envelope shape.
177
+ * @param {?} record - Sync/change record.
178
+ * @returns {Record<string, unknown>} Default serialized row.
179
+ */
180
+ defaultSerializeRecord(record) {
181
+ return {
182
+ data: this.recordData(record),
183
+ eventId: this.recordValue(record, "eventId"),
184
+ id: this.recordValue(record, "id"),
185
+ resourceId: this.recordValue(record, "resourceId"),
186
+ resourceType: this.recordValue(record, "resourceType"),
187
+ serverSequence: this.recordValue(record, "serverSequence"),
188
+ syncType: this.recordValue(record, "syncType"),
189
+ updatedAt: this.isoDate(this.recordValue(record, "updatedAt"))
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Reads and parses the record data payload.
195
+ * @param {?} record - Sync/change record.
196
+ * @returns {unknown} Parsed data value.
197
+ */
198
+ recordData(record) {
199
+ const data = this.recordValue(record, "data")
200
+
201
+ if (data === "" || data === null || data === undefined) return null
202
+ if (typeof data !== "string") return data
203
+
204
+ return JSON.parse(data)
205
+ }
206
+
207
+ /**
208
+ * Reads a value from either a record accessor method or plain property.
209
+ * @param {?} record - Sync/change record.
210
+ * @param {string} name - Camel-cased value/method name.
211
+ * @returns {unknown} Record value.
212
+ */
213
+ recordValue(record, name) {
214
+ if (!record || typeof record !== "object") {
215
+ throw new Error("Sync changes row must be an object.")
216
+ }
217
+
218
+ const recordObject = /** @type {Record<string, ?>} */ (record)
219
+ const method = recordObject[name]
220
+ const value = typeof method === "function" ? method.call(record) : method
221
+
222
+ if (value === undefined) {
223
+ throw new Error(`Sync changes row is missing ${name}.`)
224
+ }
225
+
226
+ return value
227
+ }
228
+
229
+ /**
230
+ * Converts a date-like value to an ISO string.
231
+ * @param {Date | string | null | undefined | unknown} value - Date value.
232
+ * @returns {string} ISO date.
233
+ */
234
+ isoDate(value) {
235
+ const date = value instanceof Date ? value : new Date(typeof value === "string" || typeof value === "number" ? value : 0)
236
+
237
+ if (Number.isNaN(date.getTime())) {
238
+ throw VelociousError.safe("Sync changes row has an invalid updated_at value.", {code: "sync-invalid-changes-row"})
239
+ }
240
+
241
+ return date.toISOString()
242
+ }
243
+ }
244
+