velocious 1.0.620 → 1.0.622
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/README.md +6 -2
- package/build/background-jobs/store.js +1 -1
- package/build/database/drivers/base.js +20 -3
- package/build/database/drivers/mssql/index.js +42 -36
- package/build/environment-handlers/base.js +9 -0
- package/build/environment-handlers/node.js +16 -0
- package/build/frontend-model-controller.js +20 -10
- package/build/frontend-models/base.js +90 -25
- package/build/frontend-models/remote-request-context.js +29 -0
- package/build/http-client/websocket-client.js +32 -0
- package/build/http-server/client/websocket-session.js +4 -1
- package/build/remote-request-context.js +97 -0
- package/build/src/background-jobs/store.js +1 -1
- package/build/src/database/drivers/base.d.ts +8 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -4
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +47 -41
- package/build/src/environment-handlers/base.d.ts +8 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +9 -1
- package/build/src/environment-handlers/node.d.ts +8 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +15 -1
- package/build/src/frontend-model-controller.d.ts +3 -1
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +11 -7
- package/build/src/frontend-models/base.d.ts +4 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +78 -25
- package/build/src/frontend-models/remote-request-context.d.ts +15 -0
- package/build/src/frontend-models/remote-request-context.d.ts.map +1 -0
- package/build/src/frontend-models/remote-request-context.js +26 -0
- package/build/src/http-client/websocket-client.d.ts +8 -0
- package/build/src/http-client/websocket-client.d.ts.map +1 -1
- package/build/src/http-client/websocket-client.js +31 -1
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +4 -2
- package/build/src/remote-request-context.d.ts +34 -0
- package/build/src/remote-request-context.d.ts.map +1 -0
- package/build/src/remote-request-context.js +83 -0
- package/build/src/sync/sync-client-types.d.ts +10 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +3 -1
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +31 -7
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +8 -2
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts +14 -3
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-connection-coordinator.js +44 -30
- package/build/src/testing/test-runner.js +2 -2
- package/build/sync/sync-client-types.js +2 -0
- package/build/sync/sync-client.js +31 -6
- package/build/sync/sync-realtime-bridge.js +7 -1
- package/build/testing/shared-transaction-connection-coordinator.js +51 -27
- package/build/testing/test-runner.js +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/background-jobs/store.js +1 -1
- package/src/database/drivers/base.js +20 -3
- package/src/database/drivers/mssql/index.js +42 -36
- package/src/environment-handlers/base.js +9 -0
- package/src/environment-handlers/node.js +16 -0
- package/src/frontend-model-controller.js +20 -10
- package/src/frontend-models/base.js +90 -25
- package/src/frontend-models/remote-request-context.js +29 -0
- package/src/http-client/websocket-client.js +32 -0
- package/src/http-server/client/websocket-session.js +4 -1
- package/src/remote-request-context.js +97 -0
- package/src/sync/sync-client-types.js +2 -0
- package/src/sync/sync-client.js +31 -6
- package/src/sync/sync-realtime-bridge.js +7 -1
- package/src/testing/shared-transaction-connection-coordinator.js +51 -27
- package/src/testing/test-runner.js +1 -1
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
|
|
14
14
|
* Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
|
|
15
15
|
* Controllers and views for HTTP endpoints
|
|
16
|
-
* Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md) and [docs/websocket-channels.md](docs/websocket-channels.md))
|
|
16
|
+
* Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses, immutable per-operation remote request context, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
|
|
17
17
|
* Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
|
|
18
|
-
* Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose
|
|
18
|
+
* Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose local database state plus remote pull/replay/realtime request context stay tenant-bound through reconnect (see [docs/sync-client.md](docs/sync-client.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/offline-sync.md](docs/offline-sync.md))
|
|
19
19
|
* Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
|
|
20
20
|
* Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative values for conflict resolution (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
|
|
21
21
|
* Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
|
|
@@ -797,6 +797,7 @@ When your frontend app calls a backend on another host/port (or under a path pre
|
|
|
797
797
|
import FrontendModelBase from "velocious/build/src/frontend-models/base.js"
|
|
798
798
|
|
|
799
799
|
FrontendModelBase.configureTransport({
|
|
800
|
+
requestContext: () => ({projectId: currentProject.id, routingEpoch: currentProject.routingEpoch}),
|
|
800
801
|
url: "http://127.0.0.1:4501/frontend-models",
|
|
801
802
|
timeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
802
803
|
})
|
|
@@ -805,6 +806,7 @@ FrontendModelBase.configureTransport({
|
|
|
805
806
|
Available transport options:
|
|
806
807
|
|
|
807
808
|
- `url` (can also be a relative path like `"/frontend-models"` on web)
|
|
809
|
+
- `requestContext` (a scalar plain object or synchronous function returning one) captures immutable remote tenant-routing params independently for each CRUD/custom command and event subscription. See [docs/remote-request-context.md](docs/remote-request-context.md).
|
|
808
810
|
- `timeZone` (an IANA timezone string or a function returning one). Browser clients auto-detect this when it is not configured. Frontend-model datetime strings without an explicit timezone are interpreted in this request timezone and stored/queried as UTC instants.
|
|
809
811
|
- `timeout` (milliseconds or a function returning milliseconds) bounds each request, while `signal` (an `AbortSignal` or a function returning one) supports caller cancellation. See [docs/frontend-models.md](docs/frontend-models.md#core-transport) for timeout and cancellation behavior.
|
|
810
812
|
|
|
@@ -2016,6 +2018,8 @@ await client.close()
|
|
|
2016
2018
|
|
|
2017
2019
|
For long-lived Node clients, the constructor also accepts opt-in liveness options (all default off, so browser/Expo usage is unchanged): `webSocketImplementation` (inject Node's `ws`, since the global/undici WebSocket exposes neither protocol ping nor an unref-able socket), `heartbeatIntervalMs` (a ping heartbeat that drops a socket whose peer stops ponging, so a client notices a vanished server), and `unref` (unref the underlying socket so an idle connection can't keep the process alive on its own). See [docs/websocket-channels.md](docs/websocket-channels.md).
|
|
2018
2020
|
|
|
2021
|
+
`await client.close()` is a final graceful shutdown that releases resumable server-session state; unexpected transport drops remain resumable. See [the WebSocket channel lifecycle guarantees](docs/websocket-channels.md#lifecycle-guarantees-phase-1b).
|
|
2022
|
+
|
|
2019
2023
|
## Subscribe to events
|
|
2020
2024
|
|
|
2021
2025
|
```js
|
|
@@ -7,7 +7,7 @@ import TableData from "../database/table-data/index.js"
|
|
|
7
7
|
import VelociousError from "../velocious-error.js"
|
|
8
8
|
import BackgroundJobRecord from "./job-record.js"
|
|
9
9
|
import normalizeBackgroundJobError from "./normalize-error.js"
|
|
10
|
-
import {coordinateSharedTransactionConnection} from "../testing/shared-transaction-connection-coordinator.js"
|
|
10
|
+
import { coordinateSharedTransactionConnection } from "../testing/shared-transaction-connection-coordinator.js"
|
|
11
11
|
import stableJsonStringify from "../utils/stable-json.js"
|
|
12
12
|
import {
|
|
13
13
|
BACKGROUND_JOB_EXECUTION_MODES,
|
|
@@ -171,7 +171,7 @@ import TableColumn from "../table-data/table-column.js"
|
|
|
171
171
|
import TableForeignKey from "../table-data/table-foreign-key.js"
|
|
172
172
|
import wait from "awaitery/build/wait.js"
|
|
173
173
|
import { optionalPositiveInteger } from "typanic"
|
|
174
|
-
import {
|
|
174
|
+
import {coordinateSharedTransactionConnection, runWithoutSharedTransactionCoordinatorOwner} from "../../testing/shared-transaction-connection-coordinator.js"
|
|
175
175
|
import { currentTestProfileContext } from "../../testing/test-profile-context.js"
|
|
176
176
|
import sha256Hex from "../../utils/sha256-hex.js"
|
|
177
177
|
|
|
@@ -341,9 +341,22 @@ export default class VelociousDatabaseDriversBase {
|
|
|
341
341
|
this._transactionCompletionPromise = Promise.resolve()
|
|
342
342
|
this._resolveTransactionCompletion = undefined
|
|
343
343
|
this._transactionsActionsMutex = new Mutex()
|
|
344
|
+
this._physicalConnectionMutex = new Mutex()
|
|
344
345
|
this._schemaCache = new Map()
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Serializes access to one physical database session.
|
|
350
|
+
* @template T
|
|
351
|
+
* @param {() => Promise<T>} callback - Physical driver operation.
|
|
352
|
+
* @returns {Promise<T>} - Operation result.
|
|
353
|
+
*/
|
|
354
|
+
async _runPhysicalConnectionRequest(callback) {
|
|
355
|
+
return await this._physicalConnectionMutex.sync(async () => {
|
|
356
|
+
return await runWithoutSharedTransactionCoordinatorOwner(this, callback)
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
347
360
|
/**
|
|
348
361
|
* Cleans driver-specific session state before this logical connection is reusable.
|
|
349
362
|
* Drivers whose physical sessions cannot be safely reset should dispose them here.
|
|
@@ -2014,7 +2027,9 @@ export default class VelociousDatabaseDriversBase {
|
|
|
2014
2027
|
let failed = true
|
|
2015
2028
|
|
|
2016
2029
|
try {
|
|
2017
|
-
const affectedRows = await this.
|
|
2030
|
+
const affectedRows = await this._runPhysicalConnectionRequest(
|
|
2031
|
+
async () => await this._affectedRowsActual(sql)
|
|
2032
|
+
)
|
|
2018
2033
|
|
|
2019
2034
|
failed = false
|
|
2020
2035
|
return affectedRows
|
|
@@ -2096,7 +2111,9 @@ export default class VelociousDatabaseDriversBase {
|
|
|
2096
2111
|
let failed = true
|
|
2097
2112
|
|
|
2098
2113
|
try {
|
|
2099
|
-
const result = await this.
|
|
2114
|
+
const result = await this._runPhysicalConnectionRequest(
|
|
2115
|
+
async () => await this._queryActual(sql, options)
|
|
2116
|
+
)
|
|
2100
2117
|
|
|
2101
2118
|
failed = false
|
|
2102
2119
|
return result
|
|
@@ -558,52 +558,58 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
async _startTransactionAction() {
|
|
561
|
-
|
|
562
|
-
|
|
561
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
562
|
+
if (this._currentTransaction) throw new Error("A transaction is already running")
|
|
563
|
+
if (!this.connection) await this.connect()
|
|
563
564
|
|
|
564
|
-
|
|
565
|
+
this._currentTransaction = new mssql.Transaction(this.connection)
|
|
565
566
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
567
|
+
try {
|
|
568
|
+
await this._currentTransaction.begin()
|
|
569
|
+
} catch (error) {
|
|
570
|
+
this._currentTransaction = null
|
|
571
|
+
throw error
|
|
572
|
+
}
|
|
573
|
+
})
|
|
572
574
|
}
|
|
573
575
|
|
|
574
576
|
async _commitTransactionAction() {
|
|
575
|
-
|
|
577
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
578
|
+
if (!this._currentTransaction) throw new Error("A transaction isn't running")
|
|
576
579
|
|
|
577
|
-
|
|
578
|
-
|
|
580
|
+
await this._currentTransaction.commit()
|
|
581
|
+
this._currentTransaction = null
|
|
582
|
+
})
|
|
579
583
|
}
|
|
580
584
|
|
|
581
585
|
async _rollbackTransactionAction() {
|
|
582
|
-
|
|
583
|
-
this.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
587
|
+
if (!this._currentTransaction) {
|
|
588
|
+
this.logger.debug("A transaction isn't running - ignoring because that can happen if something else has failed in the db")
|
|
589
|
+
return
|
|
590
|
+
}
|
|
586
591
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
592
|
+
try {
|
|
593
|
+
await this._currentTransaction.rollback()
|
|
594
|
+
} catch (transactionRollbackError) {
|
|
595
|
+
// When SQL Server has already aborted the transaction (e.g., a
|
|
596
|
+
// stale concurrent request triggered XACT_ABORT), the
|
|
597
|
+
// mssql.Transaction.rollback() call fails because the
|
|
598
|
+
// Transaction object is dead. Issue a raw ROLLBACK on the
|
|
599
|
+
// underlying connection to clear SQL Server's session-level
|
|
600
|
+
// aborted-transaction state so the connection is usable for the
|
|
601
|
+
// next BEGIN TRANSACTION.
|
|
602
|
+
this.logger.warn("Transaction.rollback() failed, clearing session state with raw ROLLBACK", {
|
|
603
|
+
error: transactionRollbackError instanceof Error ? transactionRollbackError.message : transactionRollbackError
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const request = new mssql.Request(this.connection)
|
|
607
|
+
|
|
608
|
+
await request.query("IF @@TRANCOUNT > 0 ROLLBACK")
|
|
609
|
+
} finally {
|
|
610
|
+
this._currentTransaction = null
|
|
611
|
+
}
|
|
612
|
+
})
|
|
607
613
|
}
|
|
608
614
|
|
|
609
615
|
/**
|
|
@@ -54,6 +54,15 @@ export default class VelociousEnvironmentHandlerBase {
|
|
|
54
54
|
*/
|
|
55
55
|
runWithSharedTransactionCoordinatorOwner(_connection, _owner, callback) { return callback() }
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Runs work without inherited shared-transaction ownership for one connection.
|
|
59
|
+
* @template T
|
|
60
|
+
* @param {import("../database/drivers/base.js").default} _connection - Physical connection whose owner is cleared.
|
|
61
|
+
* @param {() => T} callback - Physical connection work.
|
|
62
|
+
* @returns {T} - Callback result.
|
|
63
|
+
*/
|
|
64
|
+
runWithoutSharedTransactionCoordinatorOwner(_connection, callback) { return callback() }
|
|
65
|
+
|
|
57
66
|
/**
|
|
58
67
|
* Runs work without inherited shared-transaction coordinator ownership.
|
|
59
68
|
* @template T
|
|
@@ -154,6 +154,22 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
154
154
|
return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Runs work without inherited shared-transaction ownership for one connection.
|
|
159
|
+
* @template T
|
|
160
|
+
* @param {import("../database/drivers/base.js").default} connection - Physical connection whose owner is cleared.
|
|
161
|
+
* @param {() => T} callback - Physical connection work.
|
|
162
|
+
* @returns {T} - Callback result.
|
|
163
|
+
*/
|
|
164
|
+
runWithoutSharedTransactionCoordinatorOwner(connection, callback) {
|
|
165
|
+
if (!this._sharedTransactionCoordinatorAsyncLocalStorage) return callback()
|
|
166
|
+
|
|
167
|
+
const owners = new Map(this._sharedTransactionCoordinatorAsyncLocalStorage.getStore())
|
|
168
|
+
|
|
169
|
+
owners.delete(connection)
|
|
170
|
+
return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
/**
|
|
158
174
|
* Runs work without inherited shared-transaction coordinator ownership.
|
|
159
175
|
* @template T
|
|
@@ -16,6 +16,7 @@ import {requestDetails} from "./error-reporting/request-details.js"
|
|
|
16
16
|
import RoutesResolver from "./routes/resolver.js"
|
|
17
17
|
import {ValidationError} from "./database/record/index.js"
|
|
18
18
|
import RecordNotFoundError from "./database/record/record-not-found-error.js"
|
|
19
|
+
import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./frontend-models/remote-request-context.js"
|
|
19
20
|
import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
|
|
20
21
|
import VelociousError from "./velocious-error.js"
|
|
21
22
|
import isDate from "./utils/is-date.js"
|
|
@@ -4360,13 +4361,17 @@ export default class FrontendModelController extends Controller {
|
|
|
4360
4361
|
}
|
|
4361
4362
|
|
|
4362
4363
|
try {
|
|
4364
|
+
const requestContext = captureFrontendModelRemoteRequestContext(requestEntry?.requestContext)
|
|
4363
4365
|
let responsePayload
|
|
4364
4366
|
|
|
4365
4367
|
if (isBuiltInCommand) {
|
|
4366
|
-
const commandParams =
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4368
|
+
const commandParams = mergeFrontendModelRemoteRequestContext(
|
|
4369
|
+
requestContext,
|
|
4370
|
+
{
|
|
4371
|
+
...(payload && typeof payload === "object" ? payload : {}),
|
|
4372
|
+
model
|
|
4373
|
+
}
|
|
4374
|
+
)
|
|
4370
4375
|
|
|
4371
4376
|
responsePayload = await this.withFrontendModelParams(commandParams, async () => {
|
|
4372
4377
|
return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
|
|
@@ -4376,7 +4381,8 @@ export default class FrontendModelController extends Controller {
|
|
|
4376
4381
|
} else {
|
|
4377
4382
|
responsePayload = await this.frontendApiCustomCommandPayload({
|
|
4378
4383
|
customPath,
|
|
4379
|
-
payload
|
|
4384
|
+
payload,
|
|
4385
|
+
requestContext
|
|
4380
4386
|
})
|
|
4381
4387
|
}
|
|
4382
4388
|
|
|
@@ -4415,9 +4421,10 @@ export default class FrontendModelController extends Controller {
|
|
|
4415
4421
|
* @param {object} args - Arguments.
|
|
4416
4422
|
* @param {string} args.customPath - Custom backend route path.
|
|
4417
4423
|
* @param {ReturnType<typeof JSON.parse>} args.payload - Request payload.
|
|
4424
|
+
* @param {import("./remote-request-context.js").RemoteRequestContext} args.requestContext - Captured remote request context.
|
|
4418
4425
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Parsed JSON response payload.
|
|
4419
4426
|
*/
|
|
4420
|
-
async frontendApiCustomCommandPayload({customPath, payload}) {
|
|
4427
|
+
async frontendApiCustomCommandPayload({customPath, payload, requestContext}) {
|
|
4421
4428
|
const configuration = this.getConfiguration()
|
|
4422
4429
|
const response = new Response({configuration})
|
|
4423
4430
|
const resolver = new RoutesResolver({
|
|
@@ -4449,10 +4456,13 @@ export default class FrontendModelController extends Controller {
|
|
|
4449
4456
|
const viewPath = routeHookMatch?.viewPath || `${configuration.getDirectory()}/src/routes/${controller}`
|
|
4450
4457
|
resolver.routeHookControllerClass = routeHookMatch?.controllerClass
|
|
4451
4458
|
const controllerClass = await resolver.resolveControllerClass({controllerPath})
|
|
4452
|
-
const controllerParams =
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4459
|
+
const controllerParams = mergeFrontendModelRemoteRequestContext(
|
|
4460
|
+
requestContext,
|
|
4461
|
+
{
|
|
4462
|
+
...((payload && typeof payload === "object") ? payload : {}),
|
|
4463
|
+
...resolver.params
|
|
4464
|
+
}
|
|
4465
|
+
)
|
|
4456
4466
|
const controllerInstance = new controllerClass({
|
|
4457
4467
|
action,
|
|
4458
4468
|
configuration,
|
|
@@ -12,6 +12,8 @@ import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportV
|
|
|
12
12
|
import runWithTransportDeadline from "./transport-deadline.js"
|
|
13
13
|
import {REQUEST_TIME_ZONE_HEADER, validateTimeZone} from "../time-zone.js"
|
|
14
14
|
import VelociousWebsocketClient from "../http-client/websocket-client.js"
|
|
15
|
+
import {remoteRequestContextKey} from "../remote-request-context.js"
|
|
16
|
+
import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./remote-request-context.js"
|
|
15
17
|
import {bufferOutgoingEvent, clearBufferedOutgoingEvents, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
|
|
16
18
|
import {defineModelScope} from "../utils/model-scope.js"
|
|
17
19
|
import isPlainObject from "../utils/plain-object.js"
|
|
@@ -132,6 +134,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
|
|
|
132
134
|
* @property {string | (() => string | undefined | null)} [websocketUrl] - Optional websocket URL. When set, Velocious creates and manages its own websocket client internally. Subscriptions use the websocket; CRUD uses HTTP and falls back gracefully. Example: `"ws://localhost:3006/websocket"`.
|
|
133
135
|
* @property {{post: (path: string, body?: ReturnType<typeof JSON.parse>, options?: {headers?: Record<string, string>, signal?: AbortSignal}) => Promise<{json: () => ReturnType<typeof JSON.parse>}>, subscribe: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => (() => void), subscribeAndWait?: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => Promise<(() => void)>}} [websocketClient] - Optional websocket client for shared frontend-model API requests and subscriptions. Its `post` receives the bounded-deadline `signal` and should forward it into the underlying transport so the deadline can abort the live request and its response-body read.
|
|
134
136
|
* @property {Record<string, string> | (() => Record<string, string>)} [requestHeaders] - Extra HTTP/WS headers to attach to every frontend-model API request. Pass a function to compute them at request time (for example to include the current locale).
|
|
137
|
+
* @property {import("../remote-request-context.js").RemoteRequestContext | (() => import("../remote-request-context.js").RemoteRequestContext | undefined | null)} [requestContext] - Immutable scalar context captured independently when each operation or event subscription starts and sent for remote tenant/ability resolution.
|
|
135
138
|
* @property {number | (() => number | undefined | null)} [timeout] - Bounded deadline in milliseconds covering connection, response headers, and response-body consumption for each frontend-model API request. On expiry the live fetch/adapter request is aborted (built on awaitery's `timeout`) and awaitery's `TimeoutError` is thrown, so callers can classify a timeout via `error instanceof TimeoutError`. Pass a function to resolve it per request. Falsy/absent means no deadline.
|
|
136
139
|
* @property {AbortSignal | (() => AbortSignal | undefined | null)} [signal] - Optional caller/session AbortSignal composed with the deadline. Aborting it cancels the live request (for example on session shutdown or offline transition); the resulting abort error stays distinguishable from a timeout. Pass a function to resolve the current signal per request.
|
|
137
140
|
* @property {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [sessionStore] - Optional sessionId persistence hook forwarded to the internal `VelociousWebsocketClient` so WS sessions can be resumed across page reloads / app restarts.
|
|
@@ -157,8 +160,9 @@ const QUERY_DATA_KEY = "__queryData"
|
|
|
157
160
|
const ABILITIES_KEY = "__abilities"
|
|
158
161
|
/**
|
|
159
162
|
* Pending shared frontend model requests.
|
|
160
|
-
* @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
|
|
163
|
+
* @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestContext: import("../remote-request-context.js").RemoteRequestContext, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
|
|
161
164
|
let pendingSharedFrontendModelRequests = []
|
|
165
|
+
|
|
162
166
|
let sharedFrontendModelRequestId = 0
|
|
163
167
|
let sharedFrontendModelFlushScheduled = false
|
|
164
168
|
let activeFrontendModelTransportRequestCount = 0
|
|
@@ -1450,9 +1454,11 @@ class FrontendModelEventSubscription {
|
|
|
1450
1454
|
/**
|
|
1451
1455
|
* Runs constructor.
|
|
1452
1456
|
* @param {FrontendModelClass} ModelClass - Frontend model class for this subscription bucket.
|
|
1457
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
|
|
1453
1458
|
*/
|
|
1454
|
-
constructor(ModelClass) {
|
|
1459
|
+
constructor(ModelClass, requestContext) {
|
|
1455
1460
|
this.ModelClass = ModelClass
|
|
1461
|
+
this.requestContext = requestContext
|
|
1456
1462
|
/**
|
|
1457
1463
|
* Narrows the runtime value to the documented type.
|
|
1458
1464
|
* @type {Set<FrontendModelModelEventCallbackEntry>} */
|
|
@@ -1530,11 +1536,14 @@ class FrontendModelEventSubscription {
|
|
|
1530
1536
|
}
|
|
1531
1537
|
: {}
|
|
1532
1538
|
|
|
1533
|
-
return
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1539
|
+
return mergeFrontendModelRemoteRequestContext(
|
|
1540
|
+
this.requestContext,
|
|
1541
|
+
{
|
|
1542
|
+
model: this.ModelClass.getModelName(),
|
|
1543
|
+
...eventFilterParams,
|
|
1544
|
+
...projectionPayload
|
|
1545
|
+
}
|
|
1546
|
+
)
|
|
1538
1547
|
}
|
|
1539
1548
|
|
|
1540
1549
|
/**
|
|
@@ -1681,41 +1690,79 @@ class FrontendModelEventSubscription {
|
|
|
1681
1690
|
|| this.instanceListeners.size > 0
|
|
1682
1691
|
|
|
1683
1692
|
if (hasAnyListener) return
|
|
1684
|
-
if (!this.channelHandle) return
|
|
1685
1693
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1694
|
+
if (this.channelHandle) {
|
|
1695
|
+
try {
|
|
1696
|
+
this.channelHandle.close()
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
console.error(error)
|
|
1699
|
+
}
|
|
1690
1700
|
}
|
|
1691
1701
|
|
|
1692
1702
|
this.channelHandle = null
|
|
1693
1703
|
this.readyPromise = null
|
|
1694
1704
|
this.subscriptionParamsKey = null
|
|
1705
|
+
releaseFrontendModelEventSubscription(this)
|
|
1695
1706
|
}
|
|
1696
1707
|
}
|
|
1697
1708
|
|
|
1698
1709
|
/**
|
|
1699
1710
|
* Frontend model event subscriptions.
|
|
1700
|
-
* @type {WeakMap<FrontendModelClass, FrontendModelEventSubscription
|
|
1711
|
+
* @type {WeakMap<FrontendModelClass, Map<string, FrontendModelEventSubscription>>} */
|
|
1701
1712
|
const frontendModelEventSubscriptions = new WeakMap()
|
|
1702
1713
|
|
|
1703
1714
|
/**
|
|
1704
1715
|
* Runs ensure frontend model event subscription.
|
|
1705
1716
|
* @param {FrontendModelClass} ModelClass - Model class.
|
|
1717
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
|
|
1706
1718
|
* @returns {FrontendModelEventSubscription} - Per-class subscription helper.
|
|
1707
1719
|
*/
|
|
1708
|
-
function ensureFrontendModelEventSubscription(ModelClass) {
|
|
1709
|
-
let
|
|
1720
|
+
function ensureFrontendModelEventSubscription(ModelClass, requestContext) {
|
|
1721
|
+
let subscriptions = frontendModelEventSubscriptions.get(ModelClass)
|
|
1722
|
+
|
|
1723
|
+
if (!subscriptions) {
|
|
1724
|
+
subscriptions = new Map()
|
|
1725
|
+
frontendModelEventSubscriptions.set(ModelClass, subscriptions)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const contextKey = remoteRequestContextKey(requestContext)
|
|
1729
|
+
let sub = subscriptions.get(contextKey)
|
|
1710
1730
|
|
|
1711
1731
|
if (!sub) {
|
|
1712
|
-
sub = new FrontendModelEventSubscription(ModelClass)
|
|
1713
|
-
|
|
1732
|
+
sub = new FrontendModelEventSubscription(ModelClass, requestContext)
|
|
1733
|
+
subscriptions.set(contextKey, sub)
|
|
1714
1734
|
}
|
|
1715
1735
|
|
|
1716
1736
|
return sub
|
|
1717
1737
|
}
|
|
1718
1738
|
|
|
1739
|
+
/**
|
|
1740
|
+
* Removes an empty context bucket so switching through many tenants does not retain every snapshot.
|
|
1741
|
+
* @param {FrontendModelEventSubscription} subscription - Empty subscription bucket.
|
|
1742
|
+
* @returns {void}
|
|
1743
|
+
*/
|
|
1744
|
+
function releaseFrontendModelEventSubscription(subscription) {
|
|
1745
|
+
const subscriptions = frontendModelEventSubscriptions.get(subscription.ModelClass)
|
|
1746
|
+
const contextKey = remoteRequestContextKey(subscription.requestContext)
|
|
1747
|
+
|
|
1748
|
+
if (subscriptions?.get(contextKey) !== subscription) return
|
|
1749
|
+
|
|
1750
|
+
subscriptions.delete(contextKey)
|
|
1751
|
+
if (subscriptions.size === 0) frontendModelEventSubscriptions.delete(subscription.ModelClass)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Captures the current frontend-model transport context for one operation.
|
|
1756
|
+
* @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
|
|
1757
|
+
*/
|
|
1758
|
+
function frontendModelRequestContext() {
|
|
1759
|
+
const configuredContext = typeof frontendModelTransportConfig.requestContext === "function"
|
|
1760
|
+
? frontendModelTransportConfig.requestContext()
|
|
1761
|
+
: frontendModelTransportConfig.requestContext
|
|
1762
|
+
|
|
1763
|
+
return captureFrontendModelRemoteRequestContext(configuredContext)
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1719
1766
|
/**
|
|
1720
1767
|
* Runs ensure frontend model instance listener.
|
|
1721
1768
|
* @param {FrontendModelEventSubscription} sub - Event subscription bucket.
|
|
@@ -2007,6 +2054,7 @@ async function flushPendingSharedFrontendModelRequests() {
|
|
|
2007
2054
|
customPath: request.customPath,
|
|
2008
2055
|
model: request.modelClass.getModelName(),
|
|
2009
2056
|
payload: request.payload,
|
|
2057
|
+
...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
|
|
2010
2058
|
requestId: request.requestId
|
|
2011
2059
|
}
|
|
2012
2060
|
}
|
|
@@ -2015,6 +2063,7 @@ async function flushPendingSharedFrontendModelRequests() {
|
|
|
2015
2063
|
commandType: request.commandType,
|
|
2016
2064
|
model: request.modelClass.getModelName(),
|
|
2017
2065
|
payload: request.payload,
|
|
2066
|
+
...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
|
|
2018
2067
|
requestId: request.requestId
|
|
2019
2068
|
}
|
|
2020
2069
|
})
|
|
@@ -3016,6 +3065,10 @@ export default class FrontendModelBase {
|
|
|
3016
3065
|
frontendModelTransportConfig.requestHeaders = config.requestHeaders
|
|
3017
3066
|
}
|
|
3018
3067
|
|
|
3068
|
+
if (Object.prototype.hasOwnProperty.call(config, "requestContext")) {
|
|
3069
|
+
frontendModelTransportConfig.requestContext = config.requestContext
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3019
3072
|
if (Object.prototype.hasOwnProperty.call(config, "timeout")) {
|
|
3020
3073
|
frontendModelTransportConfig.timeout = config.timeout
|
|
3021
3074
|
}
|
|
@@ -3286,9 +3339,14 @@ export default class FrontendModelBase {
|
|
|
3286
3339
|
throw new Error("subscribeWebsocketChannel requires configureTransport({websocketUrl})")
|
|
3287
3340
|
}
|
|
3288
3341
|
|
|
3289
|
-
const {signal, timeoutMs, ...channelOptions} = options
|
|
3342
|
+
const {params, signal, timeoutMs, ...channelOptions} = options
|
|
3343
|
+
const requestContext = frontendModelRequestContext()
|
|
3344
|
+
const scopedParams = mergeFrontendModelRemoteRequestContext(requestContext, params === undefined ? {} : params)
|
|
3290
3345
|
const startupControls = frontendModelWebsocketStartupControls({signal, timeoutMs})
|
|
3291
|
-
const
|
|
3346
|
+
const scopedParamsOption = params === undefined && Object.keys(requestContext).length === 0
|
|
3347
|
+
? {}
|
|
3348
|
+
: {params: scopedParams}
|
|
3349
|
+
const handle = client.subscribeChannel(channelType, {...channelOptions, ...scopedParamsOption, ...startupControls})
|
|
3292
3350
|
|
|
3293
3351
|
if (typeof client.connect === "function") {
|
|
3294
3352
|
void client.connect(startupControls).catch(() => handle.close())
|
|
@@ -3649,7 +3707,7 @@ export default class FrontendModelBase {
|
|
|
3649
3707
|
* @returns {Promise<() => void>} - Unsubscribe callback.
|
|
3650
3708
|
*/
|
|
3651
3709
|
static async onCreate(callback, options = {}) {
|
|
3652
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3710
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3653
3711
|
const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
|
|
3654
3712
|
|
|
3655
3713
|
sub.classCreateCallbacks.add(entry)
|
|
@@ -3669,7 +3727,7 @@ export default class FrontendModelBase {
|
|
|
3669
3727
|
* @returns {Promise<() => void>} - Unsubscribe callback.
|
|
3670
3728
|
*/
|
|
3671
3729
|
static async onUpdate(callback, options = {}) {
|
|
3672
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3730
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3673
3731
|
const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
|
|
3674
3732
|
|
|
3675
3733
|
sub.classUpdateCallbacks.add(entry)
|
|
@@ -3691,7 +3749,7 @@ export default class FrontendModelBase {
|
|
|
3691
3749
|
static async onDestroy(callback, options = {}) {
|
|
3692
3750
|
assertNoDestroyEventFilter(this, options)
|
|
3693
3751
|
|
|
3694
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3752
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3695
3753
|
const entry = {callback}
|
|
3696
3754
|
|
|
3697
3755
|
sub.classDestroyCallbacks.add(entry)
|
|
@@ -3715,7 +3773,7 @@ export default class FrontendModelBase {
|
|
|
3715
3773
|
async onUpdate(callback, options = {}) {
|
|
3716
3774
|
const self = /** @type {ReturnType<typeof JSON.parse>} */ (this)
|
|
3717
3775
|
const ModelClass = frontendModelClassFor(this)
|
|
3718
|
-
const sub = ensureFrontendModelEventSubscription(ModelClass)
|
|
3776
|
+
const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
|
|
3719
3777
|
const id = String(self.id())
|
|
3720
3778
|
const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
|
|
3721
3779
|
const listener = ensureFrontendModelInstanceListener(sub, id, this)
|
|
@@ -3748,7 +3806,7 @@ export default class FrontendModelBase {
|
|
|
3748
3806
|
|
|
3749
3807
|
assertNoDestroyEventFilter(ModelClass, options)
|
|
3750
3808
|
|
|
3751
|
-
const sub = ensureFrontendModelEventSubscription(ModelClass)
|
|
3809
|
+
const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
|
|
3752
3810
|
const id = String(self.id())
|
|
3753
3811
|
const entry = {callback}
|
|
3754
3812
|
const listener = ensureFrontendModelInstanceListener(sub, id, this)
|
|
@@ -4596,6 +4654,8 @@ export default class FrontendModelBase {
|
|
|
4596
4654
|
const commandName = this.commandName(commandType)
|
|
4597
4655
|
const timeZone = frontendModelTransportTimeZone()
|
|
4598
4656
|
const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
|
|
4657
|
+
const requestContext = frontendModelRequestContext()
|
|
4658
|
+
const requestPayload = mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
|
|
4599
4659
|
const resourcePath = this.resourcePath()
|
|
4600
4660
|
const containsAttachmentUpload = frontendModelPayloadContainsAttachmentUpload(serializedPayload)
|
|
4601
4661
|
const useSharedTransport = !containsAttachmentUpload
|
|
@@ -4608,6 +4668,7 @@ export default class FrontendModelBase {
|
|
|
4608
4668
|
commandType,
|
|
4609
4669
|
modelClass: this,
|
|
4610
4670
|
payload: serializedPayload,
|
|
4671
|
+
requestContext,
|
|
4611
4672
|
reject,
|
|
4612
4673
|
requestId: `${++sharedFrontendModelRequestId}`,
|
|
4613
4674
|
resolve,
|
|
@@ -4635,7 +4696,7 @@ export default class FrontendModelBase {
|
|
|
4635
4696
|
},
|
|
4636
4697
|
async (signal) => {
|
|
4637
4698
|
const directResponse = await fetch(url, {
|
|
4638
|
-
body: JSON.stringify(
|
|
4699
|
+
body: JSON.stringify(requestPayload),
|
|
4639
4700
|
credentials: "include",
|
|
4640
4701
|
headers: frontendModelRequestHeaders(timeZone),
|
|
4641
4702
|
method: "POST",
|
|
@@ -4675,6 +4736,9 @@ export default class FrontendModelBase {
|
|
|
4675
4736
|
const {commandName, commandType, memberId = null, payload, resourcePath} = args
|
|
4676
4737
|
const timeZone = frontendModelTransportTimeZone()
|
|
4677
4738
|
const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
|
|
4739
|
+
const requestContext = frontendModelRequestContext()
|
|
4740
|
+
|
|
4741
|
+
mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
|
|
4678
4742
|
const customPath = frontendModelCustomCommandPath({
|
|
4679
4743
|
commandName,
|
|
4680
4744
|
memberId,
|
|
@@ -4688,6 +4752,7 @@ export default class FrontendModelBase {
|
|
|
4688
4752
|
customPath,
|
|
4689
4753
|
modelClass: this,
|
|
4690
4754
|
payload: serializedPayload,
|
|
4755
|
+
requestContext,
|
|
4691
4756
|
reject,
|
|
4692
4757
|
requestId: `${++sharedFrontendModelRequestId}`,
|
|
4693
4758
|
resolve
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
|
|
4
|
+
|
|
5
|
+
const RESERVED_KEYS = ["commandType", "customPath", "model", "payload", "requestContext", "requestId", "requests"]
|
|
6
|
+
const REQUEST_CONTEXT_LABEL = "Frontend model request context"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Captures one frontend-model operation's immutable remote request context.
|
|
10
|
+
* @param {ReturnType<typeof JSON.parse> | undefined} value - Configured or untrusted context value.
|
|
11
|
+
* @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
|
|
12
|
+
*/
|
|
13
|
+
export function captureFrontendModelRemoteRequestContext(value) {
|
|
14
|
+
return captureRemoteRequestContext(value, {
|
|
15
|
+
label: REQUEST_CONTEXT_LABEL,
|
|
16
|
+
reservedKeys: RESERVED_KEYS
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Merges captured context into frontend-model command or subscription params.
|
|
22
|
+
* @template {Record<string, ReturnType<typeof JSON.parse>>} TParams
|
|
23
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} context - Captured context.
|
|
24
|
+
* @param {TParams} params - Framework-owned params.
|
|
25
|
+
* @returns {TParams & import("../remote-request-context.js").RemoteRequestContext} Merged params.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeFrontendModelRemoteRequestContext(context, params) {
|
|
28
|
+
return mergeRemoteRequestContext({context, label: REQUEST_CONTEXT_LABEL, params})
|
|
29
|
+
}
|