velocious 1.0.621 → 1.0.623

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 (77) hide show
  1. package/README.md +7 -3
  2. package/build/database/drivers/base.js +20 -3
  3. package/build/database/drivers/mssql/index.js +42 -36
  4. package/build/environment-handlers/base.js +9 -0
  5. package/build/environment-handlers/node.js +16 -0
  6. package/build/frontend-model-controller.js +20 -10
  7. package/build/frontend-models/base.js +90 -25
  8. package/build/frontend-models/remote-request-context.js +29 -0
  9. package/build/http-client/websocket-client.js +32 -0
  10. package/build/http-server/client/websocket-session.js +4 -1
  11. package/build/remote-request-context.js +97 -0
  12. package/build/src/database/drivers/base.d.ts +8 -0
  13. package/build/src/database/drivers/base.d.ts.map +1 -1
  14. package/build/src/database/drivers/base.js +16 -4
  15. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  16. package/build/src/database/drivers/mssql/index.js +47 -41
  17. package/build/src/environment-handlers/base.d.ts +8 -0
  18. package/build/src/environment-handlers/base.d.ts.map +1 -1
  19. package/build/src/environment-handlers/base.js +9 -1
  20. package/build/src/environment-handlers/node.d.ts +8 -0
  21. package/build/src/environment-handlers/node.d.ts.map +1 -1
  22. package/build/src/environment-handlers/node.js +15 -1
  23. package/build/src/frontend-model-controller.d.ts +3 -1
  24. package/build/src/frontend-model-controller.d.ts.map +1 -1
  25. package/build/src/frontend-model-controller.js +11 -7
  26. package/build/src/frontend-models/base.d.ts +4 -0
  27. package/build/src/frontend-models/base.d.ts.map +1 -1
  28. package/build/src/frontend-models/base.js +78 -25
  29. package/build/src/frontend-models/remote-request-context.d.ts +15 -0
  30. package/build/src/frontend-models/remote-request-context.d.ts.map +1 -0
  31. package/build/src/frontend-models/remote-request-context.js +26 -0
  32. package/build/src/http-client/websocket-client.d.ts +8 -0
  33. package/build/src/http-client/websocket-client.d.ts.map +1 -1
  34. package/build/src/http-client/websocket-client.js +31 -1
  35. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  36. package/build/src/http-server/client/websocket-session.js +4 -2
  37. package/build/src/remote-request-context.d.ts +34 -0
  38. package/build/src/remote-request-context.d.ts.map +1 -0
  39. package/build/src/remote-request-context.js +83 -0
  40. package/build/src/sync/sync-client-types.d.ts +10 -0
  41. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  42. package/build/src/sync/sync-client-types.js +3 -1
  43. package/build/src/sync/sync-client.d.ts.map +1 -1
  44. package/build/src/sync/sync-client.js +31 -7
  45. package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
  46. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  47. package/build/src/sync/sync-envelope-replay-service.js +18 -11
  48. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  49. package/build/src/sync/sync-realtime-bridge.js +8 -2
  50. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +9 -0
  51. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  52. package/build/src/testing/shared-transaction-connection-coordinator.js +15 -1
  53. package/build/src/testing/test-runner.js +2 -2
  54. package/build/sync/sync-client-types.js +2 -0
  55. package/build/sync/sync-client.js +31 -6
  56. package/build/sync/sync-envelope-replay-service.js +17 -10
  57. package/build/sync/sync-realtime-bridge.js +7 -1
  58. package/build/testing/shared-transaction-connection-coordinator.js +16 -0
  59. package/build/testing/test-runner.js +1 -1
  60. package/build/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +1 -1
  62. package/src/database/drivers/base.js +20 -3
  63. package/src/database/drivers/mssql/index.js +42 -36
  64. package/src/environment-handlers/base.js +9 -0
  65. package/src/environment-handlers/node.js +16 -0
  66. package/src/frontend-model-controller.js +20 -10
  67. package/src/frontend-models/base.js +90 -25
  68. package/src/frontend-models/remote-request-context.js +29 -0
  69. package/src/http-client/websocket-client.js +32 -0
  70. package/src/http-server/client/websocket-session.js +4 -1
  71. package/src/remote-request-context.js +97 -0
  72. package/src/sync/sync-client-types.js +2 -0
  73. package/src/sync/sync-client.js +31 -6
  74. package/src/sync/sync-envelope-replay-service.js +17 -10
  75. package/src/sync/sync-realtime-bridge.js +7 -1
  76. package/src/testing/shared-transaction-connection-coordinator.js +16 -0
  77. package/src/testing/test-runner.js +1 -1
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+
3
+ import VelociousError from "./velocious-error.js"
4
+ import isPlainObject from "./utils/plain-object.js"
5
+
6
+ /** @typedef {Readonly<Record<string, string | number | boolean>>} RemoteRequestContext */
7
+
8
+ const UNSAFE_CONTEXT_KEYS = new Set(["__proto__", "constructor", "prototype"])
9
+
10
+ /**
11
+ * Captures and validates immutable scalar context for one remote operation.
12
+ * @param {ReturnType<typeof JSON.parse> | undefined} value - Configured context value.
13
+ * @param {object} [args] - Validation options.
14
+ * @param {string} [args.label] - Context label used in errors.
15
+ * @param {Iterable<string>} [args.reservedKeys] - Framework-owned keys unavailable to context.
16
+ * @returns {RemoteRequestContext} Frozen context snapshot.
17
+ */
18
+ export function captureRemoteRequestContext(value, {label = "Remote request context", reservedKeys = []} = {}) {
19
+ if (value === undefined || value === null) return Object.freeze({})
20
+
21
+ if (!isPlainObject(value)) {
22
+ throw remoteRequestContextError(`${label} must be a plain object of scalar values`)
23
+ }
24
+
25
+ const reservedKeySet = new Set(reservedKeys)
26
+ /** @type {Record<string, string | number | boolean>} */
27
+ const context = {}
28
+
29
+ for (const key of Object.keys(value).sort()) {
30
+ if (!key.trim()) throw remoteRequestContextError(`${label} keys must be non-blank strings`)
31
+ if (UNSAFE_CONTEXT_KEYS.has(key) || reservedKeySet.has(key)) {
32
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} is reserved by the framework`)
33
+ }
34
+
35
+ const contextValue = value[key]
36
+
37
+ if (!remoteRequestContextScalar(contextValue)) {
38
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} must contain a string, finite number, or boolean scalar`)
39
+ }
40
+
41
+ context[key] = contextValue
42
+ }
43
+
44
+ return Object.freeze(context)
45
+ }
46
+
47
+ /**
48
+ * Merges captured context into framework request params without ambiguity.
49
+ * @template {Record<string, ReturnType<typeof JSON.parse>>} TParams
50
+ * @param {object} args - Merge arguments.
51
+ * @param {RemoteRequestContext} args.context - Captured context.
52
+ * @param {string} [args.label] - Context label used in errors.
53
+ * @param {TParams} args.params - Framework-owned request params.
54
+ * @returns {TParams & RemoteRequestContext} Merged params, or the original params when unscoped.
55
+ */
56
+ export function mergeRemoteRequestContext({context, label = "Remote request context", params}) {
57
+ const contextKeys = Object.keys(context)
58
+
59
+ if (contextKeys.length === 0) return params
60
+
61
+ for (const key of contextKeys) {
62
+ if (Object.hasOwn(params, key)) {
63
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} is reserved by the request payload`)
64
+ }
65
+ }
66
+
67
+ return {...params, ...context}
68
+ }
69
+
70
+ /**
71
+ * Returns a stable identity for an immutable captured context.
72
+ * @param {RemoteRequestContext} context - Captured context.
73
+ * @returns {string} Stable serialized key.
74
+ */
75
+ export function remoteRequestContextKey(context) {
76
+ return JSON.stringify(context)
77
+ }
78
+
79
+ /**
80
+ * Checks whether a value is a supported request-context scalar.
81
+ * @param {ReturnType<typeof JSON.parse>} value - Candidate scalar.
82
+ * @returns {value is string | number | boolean} Whether the value is supported.
83
+ */
84
+ function remoteRequestContextScalar(value) {
85
+ if (["string", "boolean"].includes(typeof value)) return true
86
+
87
+ return typeof value === "number" && Number.isFinite(value)
88
+ }
89
+
90
+ /**
91
+ * Builds a client-safe request-context validation error.
92
+ * @param {string} message - Safe validation message.
93
+ * @returns {VelociousError} Validation error.
94
+ */
95
+ function remoteRequestContextError(message) {
96
+ return VelociousError.safe(message, {code: "remote-request-context-invalid", errorType: "validation_error"})
97
+ }
@@ -90,6 +90,7 @@
90
90
  * @typedef {object} SyncClientOptions
91
91
  * @property {import("../configuration.js").default} [configuration] - Configuration owning the registered models, the `sync.client` block, and the scope-store database. Defaults to the current configuration.
92
92
  * @property {(args: {scope: SerializedSyncScope}) => string | null | Promise<string | null>} [legacyCursor] - Seeds a newly declared scope's cursor (e.g. from a pre-scope cursor store) so devices don't re-pull everything.
93
+ * @property {import("../remote-request-context.js").RemoteRequestContext} [requestContext] - Immutable scalar context captured for this client and sent with every pull, replay, and realtime subscription.
93
94
  * @property {import("./sync-scope-store.js").default} [scopeStore] - Scope store override (tests).
94
95
  * @property {ReturnType<typeof JSON.parse>} [syncModel] - Pending-sync model override. Defaults to the registered "Sync" model.
95
96
  * @property {string} [databaseIdentifier] - Tenant database identifier; required with tenantHandle.
@@ -109,6 +110,7 @@
109
110
  * @property {(payload: import("./sync-api-client-types.js").SyncChangesRequest & {scope: SerializedSyncScope}) => Promise<import("./sync-api-client-types.js").SyncChangesResponse>} postChanges - Posts one changes request.
110
111
  * @property {(payload: {authenticationToken: string, syncs: Array<Record<string, ReturnType<typeof JSON.parse>>>}) => Promise<import("./sync-api-client-types.js").SyncReplayResponse>} postReplay - Posts one replay request.
111
112
  * @property {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
113
+ * @property {import("../remote-request-context.js").RemoteRequestContext} requestContext - Immutable scalar context captured for this client.
112
114
  * @property {Record<string, SyncClientResourceConfig>} resources - Derived resource policies keyed by resource/model name.
113
115
  * @property {ReturnType<typeof JSON.parse>} syncModel - Local pending-sync model class.
114
116
  * @property {string} [databaseIdentifier] - Tenant-scoped database identifier.
@@ -3,6 +3,7 @@
3
3
  import Configuration from "../configuration.js"
4
4
  import {isBooleanColumnType} from "../database/column-types.js"
5
5
  import Logger from "../logger.js"
6
+ import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
6
7
  import restArgsError from "../utils/rest-args-error.js"
7
8
  import VelociousWebsocketClient from "../http-client/websocket-client.js"
8
9
 
@@ -28,6 +29,20 @@ const DEFAULT_TRACKED_OPERATIONS = ["create", "update"]
28
29
  /** Attribute names treated as client-local sync bookkeeping when deriving localOnlyAttributes. */
29
30
  const LOCAL_BOOKKEEPING_ATTRIBUTE_NAMES = ["createdAt", "updatedAt", "lastSyncChangeAt"]
30
31
 
32
+ const SYNC_REQUEST_RESERVED_KEYS = [
33
+ "afterId",
34
+ "afterServerSequence",
35
+ "afterUpdatedAt",
36
+ "authenticationToken",
37
+ "limit",
38
+ "scope",
39
+ "syncs",
40
+ "upstreamRefresh",
41
+ "upToId",
42
+ "upToServerSequence",
43
+ "upToUpdatedAt"
44
+ ]
45
+
31
46
  /** @type {WeakMap<Configuration, SyncClient>} */
32
47
  const syncClientsByConfiguration = new WeakMap()
33
48
 
@@ -56,11 +71,15 @@ export default class SyncClient {
56
71
  * @param {import("./sync-client-types.js").SyncClientOptions} [options] - Optional overrides.
57
72
  */
58
73
  constructor(options = {}) {
59
- const {configuration = Configuration.current(), databaseIdentifier, legacyCursor, scopeStore, syncModel, tenantHandle, ...restOptions} = options
74
+ const {configuration = Configuration.current(), databaseIdentifier, legacyCursor, requestContext, scopeStore, syncModel, tenantHandle, ...restOptions} = options
60
75
 
61
76
  restArgsError(restOptions)
62
77
 
63
78
  const clientConfiguration = configuration.getSyncConfiguration().client
79
+ const capturedRequestContext = captureRemoteRequestContext(requestContext, {
80
+ label: "Sync client request context",
81
+ reservedKeys: SYNC_REQUEST_RESERVED_KEYS
82
+ })
64
83
 
65
84
  if (!clientConfiguration) {
66
85
  throw new Error("SyncClient requires a sync.client configuration block: new Configuration({sync: {client: {authenticationToken, transport}}})")
@@ -121,9 +140,10 @@ export default class SyncClient {
121
140
  isOnline: clientConfiguration.isOnline,
122
141
  legacyCursor,
123
142
  onError: clientConfiguration.onError,
124
- postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, transport: clientConfiguration.transport}),
125
- postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, transport: clientConfiguration.transport}),
143
+ postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
144
+ postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
126
145
  realtime: clientConfiguration.realtime,
146
+ requestContext: capturedRequestContext,
127
147
  resources,
128
148
  syncModel: resolvedSyncModel,
129
149
  tenantHandle,
@@ -1267,12 +1287,17 @@ function normalizedTrack(track) {
1267
1287
 
1268
1288
  /**
1269
1289
  * Builds a framework-owned sync endpoint POSTer over the configured transport.
1270
- * @param {{path: string, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.
1290
+ * @param {{path: string, requestContext: import("../remote-request-context.js").RemoteRequestContext, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.
1271
1291
  * @returns {(payload: Record<string, ReturnType<typeof JSON.parse>>) => Promise<ReturnType<typeof JSON.parse>>} Sync endpoint POSTer.
1272
1292
  */
1273
- function transportPoster({path, transport}) {
1293
+ function transportPoster({path, requestContext, transport}) {
1274
1294
  return async (payload) => {
1275
- const response = await transport.post(path, payload)
1295
+ const requestPayload = mergeRemoteRequestContext({
1296
+ context: requestContext,
1297
+ label: "Sync client request context",
1298
+ params: payload
1299
+ })
1300
+ const response = await transport.post(path, requestPayload)
1276
1301
 
1277
1302
  if (!response || typeof response.json !== "function") {
1278
1303
  throw new Error(`sync.client transport.post must resolve to a response with a json() method for ${path} (like the frontend-model websocket client)`)
@@ -798,18 +798,24 @@ export default class SyncEnvelopeReplayService {
798
798
  if (!existingRecord || mutation.syncType === "create") return null
799
799
  if (mutation.baseVersion === undefined || mutation.baseVersion === null) return null
800
800
 
801
+ const ModelClass = resource.modelClass()
802
+ const primaryKey = ModelClass.primaryKey()
803
+ const primaryKeyAttribute = ModelClass.resolveAttributeName(primaryKey)
801
804
  const versionAttribute = this.conflictStrategy.versionAttribute
802
- const serverVersion = normalizeConflictValue(existingRecord.readAttribute(versionAttribute))
805
+ const versionAttributeName = ModelClass.resolveAttributeName(versionAttribute)
806
+
807
+ if (!primaryKeyAttribute) throw new Error(`Couldn't resolve primary key attribute: ${primaryKey}`)
808
+ if (!versionAttributeName) throw new Error(`Couldn't resolve version attribute: ${versionAttribute}`)
809
+
810
+ const serverVersion = normalizeConflictValue(existingRecord.readAttribute(versionAttributeName))
803
811
 
804
812
  if (stableJsonStringify(serverVersion) === stableJsonStringify(mutation.baseVersion)) return null
805
813
 
806
- const ModelClass = resource.modelClass()
807
- const primaryKey = ModelClass.primaryKey()
808
814
  const serializedAffectedAttributes = await this.serializedRoutedConflictAttributes({attributes, existingRecord, resource})
809
815
  const serverAttributes = {
810
816
  ...serializedAffectedAttributes,
811
- [primaryKey]: existingRecord.readAttribute(primaryKey),
812
- [versionAttribute]: serverVersion
817
+ [primaryKeyAttribute]: existingRecord.readAttribute(primaryKeyAttribute),
818
+ [versionAttributeName]: serverVersion
813
819
  }
814
820
 
815
821
  const serverRecord = {
@@ -842,8 +848,9 @@ export default class SyncEnvelopeReplayService {
842
848
  * attribute contract. Writable-but-hidden fields are omitted, while custom
843
849
  * `<attribute>Attribute(model)` serializers and model accessors remain the
844
850
  * source of frontend-visible values (Date values are kept raw so the normal
845
- * frontend-model transport serializer can emit its date marker). The full
846
- * model attribute hash is never exposed.
851
+ * frontend-model transport serializer can emit its date marker). Projected
852
+ * keys use canonical model attribute names even when the mutation used a
853
+ * database-column alias. The full model attribute hash is never exposed.
847
854
  * @param {object} args - Projection args.
848
855
  * @param {Record<string, ReturnType<typeof JSON.parse>>} args.attributes - Permitted affected mutation attributes.
849
856
  * @param {import("../database/record/index.js").default} args.existingRecord - Authorized server record.
@@ -886,7 +893,7 @@ export default class SyncEnvelopeReplayService {
886
893
  const resourceAttribute = resource.resourceMethod(`${attributeName}Attribute`)
887
894
 
888
895
  if (resourceAttribute) {
889
- serializedAttributes[affectedField] = await resourceAttribute.method.call(resourceAttribute.resource, existingRecord)
896
+ serializedAttributes[attributeName] = await resourceAttribute.method.call(resourceAttribute.resource, existingRecord)
890
897
  continue
891
898
  }
892
899
 
@@ -894,9 +901,9 @@ export default class SyncEnvelopeReplayService {
894
901
  const attributeMethod = recordMethods[attributeName]
895
902
 
896
903
  if (typeof attributeMethod === "function") {
897
- serializedAttributes[affectedField] = await attributeMethod.call(existingRecord)
904
+ serializedAttributes[attributeName] = await attributeMethod.call(existingRecord)
898
905
  } else {
899
- serializedAttributes[affectedField] = existingRecord.readAttribute(attributeName)
906
+ serializedAttributes[attributeName] = existingRecord.readAttribute(attributeName)
900
907
  }
901
908
  }
902
909
 
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import recordChanges from "../database/record-changes.js"
4
+ import {mergeRemoteRequestContext} from "../remote-request-context.js"
4
5
 
5
6
  import SyncApiClient from "./sync-api-client.js"
6
7
  import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
@@ -141,10 +142,15 @@ export default class SyncRealtimeBridge {
141
142
  }
142
143
 
143
144
  const resourceType = channelDescriptor.resourceType ?? null
145
+ const params = mergeRemoteRequestContext({
146
+ context: this.syncClient.config.requestContext,
147
+ label: "Sync client request context",
148
+ params: {...channelDescriptor.params, authenticationToken}
149
+ })
144
150
  const subscription = client.subscribeChannel(channelDescriptor.channel, {
145
151
  onMessage: (body) => this.enqueueApply({body, resourceType}),
146
152
  onResume: () => this.schedulePull(),
147
- params: {...channelDescriptor.params, authenticationToken}
153
+ params
148
154
  })
149
155
 
150
156
  channels.push({channel: channelDescriptor.channel, resourceType, subscription})
@@ -157,3 +157,19 @@ export async function coordinateSharedTransactionConnection(connection, callback
157
157
  return await coordinateRootSharedTransactionConnection(connection, registration, callback)
158
158
  }))
159
159
  }
160
+
161
+ /**
162
+ * Runs physical query work without inheriting this connection's coordinator owner.
163
+ * Unregistered connections have no coordinator ownership to clear.
164
+ * @template T
165
+ * @param {import("../database/drivers/base.js").default} connection - Physical connection.
166
+ * @param {() => Promise<T>} callback - Physical query work.
167
+ * @returns {Promise<T>} - Callback result.
168
+ */
169
+ export async function runWithoutSharedTransactionCoordinatorOwner(connection, callback) {
170
+ if (!connectionRegistrations.has(connection)) return await callback()
171
+
172
+ const environmentHandler = connection.configuration.getEnvironmentHandler()
173
+
174
+ return await environmentHandler.runWithoutSharedTransactionCoordinatorOwner(connection, callback)
175
+ }
@@ -1436,7 +1436,7 @@ export default class TestRunner {
1436
1436
  testSharedConnectionsActive = true
1437
1437
 
1438
1438
  try {
1439
- if (testArgs.databaseCleaning?.transaction === true) {
1439
+ if (testArgs.databaseCleaning?.transaction === true || testArgs.type == "request") {
1440
1440
  sharedTransactionBrokerPreparation = await this.prepareSharedTransactionBroker()
1441
1441
  }
1442
1442