velocious 1.0.557 → 1.0.558

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 (47) hide show
  1. package/README.md +16 -2
  2. package/build/background-jobs/main.js +35 -12
  3. package/build/background-jobs/worker.js +53 -28
  4. package/build/database/record/index.js +12 -0
  5. package/build/frontend-models/base.js +192 -41
  6. package/build/http-client/websocket-client.js +48 -0
  7. package/build/src/background-jobs/main.d.ts +14 -1
  8. package/build/src/background-jobs/main.d.ts.map +1 -1
  9. package/build/src/background-jobs/main.js +37 -14
  10. package/build/src/background-jobs/worker.d.ts +18 -1
  11. package/build/src/background-jobs/worker.d.ts.map +1 -1
  12. package/build/src/background-jobs/worker.js +55 -31
  13. package/build/src/database/record/index.d.ts.map +1 -1
  14. package/build/src/database/record/index.js +9 -1
  15. package/build/src/frontend-models/base.d.ts +13 -4
  16. package/build/src/frontend-models/base.d.ts.map +1 -1
  17. package/build/src/frontend-models/base.js +173 -44
  18. package/build/src/http-client/websocket-client.d.ts +3 -0
  19. package/build/src/http-client/websocket-client.d.ts.map +1 -1
  20. package/build/src/http-client/websocket-client.js +42 -1
  21. package/build/src/tenants/tenant.d.ts +20 -9
  22. package/build/src/tenants/tenant.d.ts.map +1 -1
  23. package/build/src/tenants/tenant.js +47 -12
  24. package/build/src/testing/factory/factory-registry.d.ts +22 -3
  25. package/build/src/testing/factory/factory-registry.d.ts.map +1 -1
  26. package/build/src/testing/factory/factory-registry.js +32 -9
  27. package/build/src/testing/factory/factory-runner.d.ts +16 -4
  28. package/build/src/testing/factory/factory-runner.d.ts.map +1 -1
  29. package/build/src/testing/factory/factory-runner.js +33 -10
  30. package/build/src/utils/shutdown-lifecycle.d.ts +12 -0
  31. package/build/src/utils/shutdown-lifecycle.d.ts.map +1 -0
  32. package/build/src/utils/shutdown-lifecycle.js +36 -0
  33. package/build/tenants/tenant.js +51 -11
  34. package/build/testing/factory/factory-registry.js +33 -8
  35. package/build/testing/factory/factory-runner.js +38 -12
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/build/utils/shutdown-lifecycle.js +41 -0
  38. package/package.json +3 -2
  39. package/src/background-jobs/main.js +35 -12
  40. package/src/background-jobs/worker.js +53 -28
  41. package/src/database/record/index.js +12 -0
  42. package/src/frontend-models/base.js +192 -41
  43. package/src/http-client/websocket-client.js +48 -0
  44. package/src/tenants/tenant.js +51 -11
  45. package/src/testing/factory/factory-registry.js +33 -8
  46. package/src/testing/factory/factory-runner.js +38 -12
  47. package/src/utils/shutdown-lifecycle.js +41 -0
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  * Migrations for schema changes and UTC datetime storage (see [docs/database-migrations.md](docs/database-migrations.md))
11
11
  * External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
12
12
  * Controllers and views for HTTP endpoints
13
- * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses (see [docs/frontend-models.md](docs/frontend-models.md))
13
+ * 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))
14
14
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see [docs/offline-sync.md](docs/offline-sync.md))
15
15
  * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, realtime delivery, and `sync`/`pull` progress reporting for "X of Y" import screens (see [docs/sync-client.md](docs/sync-client.md))
16
16
  * Reactive `useLiveQuery(Model.where(...))` queries that stay current from committed local model changes across local writes, pulls, and realtime (see [docs/live-queries.md](docs/live-queries.md))
@@ -75,6 +75,11 @@ Baselines are generated against a fresh checkout (no generated dummy `configurat
75
75
 
76
76
  # Testing
77
77
 
78
+ The dummy database configurations require `MSSQL_SA_PASSWORD` whenever they include
79
+ the shared MSSQL test database. Set it in the local process environment rather than
80
+ writing the password into `spec/dummy/src/config/configuration*.js`. TensorBuzz CI
81
+ provides one shared test value to both the build and MSSQL service environments.
82
+
78
83
  Tag tests to filter runs.
79
84
 
80
85
  ```js
@@ -2139,6 +2144,15 @@ Inline jobs share the worker process and run concurrently up to `maxConcurrentIn
2139
2144
  new BackgroundJobsWorker({configuration, maxConcurrentInlineJobs: 8})
2140
2145
  ```
2141
2146
 
2147
+ Standalone background-jobs main and worker processes close their configuration's
2148
+ database pools during `stop()`. Embedded/test callers that share externally owned
2149
+ pools can pass `closeDatabaseConnectionsOnStop: false` to `BackgroundJobsMain` or
2150
+ `BackgroundJobsWorker`; sockets and Beacon still shut down normally. An async
2151
+ `onStopped` constructor hook can coordinate externally owned cleanup after that
2152
+ shutdown without wrapping the service's `stop()` method. Repeated `stop()` calls
2153
+ share one lifecycle and invoke the hook once; dual shutdown/hook failures reject
2154
+ with an `AggregateError` ordered with the shutdown failure first.
2155
+
2142
2156
  ## Scheduled jobs
2143
2157
 
2144
2158
  Velocious can enqueue recurring jobs from the `background-jobs-main` process. Configure them with `scheduledBackgroundJobs` using Sidekiq Scheduler-style `every` arrays:
@@ -2383,7 +2397,7 @@ Tenant lifecycle commands print start and final counts, report each completed te
2383
2397
 
2384
2398
  `afterMigrateTenant` hooks run inside the active default and tenant database connection scope for the tenant being migrated.
2385
2399
 
2386
- At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a tenant context, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. `Tenant.with` and `Tenant.each` run their callbacks inside `ensureConnections`, so switching into a tenant establishes its database connections (global + tenant) and passes them to the callback — the tenant is immediately queryable without the caller wiring up connections (already-open connections are reused, so nesting does not double-connect). `Tenant.with` is generic over its callback's return type, so a value returned from inside the tenant context comes back to the caller with its type preserved (no cast needed). `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2400
+ At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a tenant context, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. `Tenant.with` and `Tenant.each` run their callbacks inside `ensureConnections`, so switching into a tenant establishes its database connections (global + tenant), initializes registered tenant-switched models whose tables exist, and passes the connections to the callback — the tenant is immediately queryable without caller-owned connection or model initialization (already-open connections and in-progress model initialization promises are reused). Models for absent optional tables stay deferred. `Tenant.with` is generic over its callback's return type, so a value returned from inside the tenant context comes back to the caller with its type preserved (no cast needed). `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2387
2401
 
2388
2402
  `SchemaCloner` adds a missing auto-increment column and its separate source unique index in one schema alteration, including on MySQL/MariaDB where an auto-increment column must be keyed when it is created.
2389
2403
 
@@ -7,6 +7,7 @@ import BackgroundJobsStore from "./store.js"
7
7
  import Logger from "../logger.js"
8
8
  import PruneTerminalBackgroundJobsJob from "../jobs/prune-terminal-background-jobs.js"
9
9
  import VelociousError from "../velocious-error.js"
10
+ import shutdownLifecycle from "../utils/shutdown-lifecycle.js"
10
11
 
11
12
  /**
12
13
  * Channel used by `background-jobs-main` to coordinate dispatch wake-ups
@@ -60,9 +61,13 @@ export default class BackgroundJobsMain {
60
61
  * @param {number} [args.port] - Port.
61
62
  * @param {number} [args.workerStaleTimeoutMs] - Override how long a silent worker may go before being dropped (default 60000ms).
62
63
  * @param {number} [args.workerLivenessSweepMs] - Override how often stale workers are swept for (default 15000ms).
64
+ * @param {boolean} [args.closeDatabaseConnectionsOnStop] - Whether stop owns closing the configuration's database pools (default true).
65
+ * @param {() => void | Promise<void>} [args.onStopped] - Lifecycle hook invoked after the main process finishes stopping.
63
66
  */
64
- constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs}) {
67
+ constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs, closeDatabaseConnectionsOnStop = true, onStopped}) {
65
68
  this.configuration = configuration
69
+ this.closeDatabaseConnectionsOnStop = closeDatabaseConnectionsOnStop
70
+ this.onStopped = onStopped
66
71
  const config = configuration.getBackgroundJobsConfig()
67
72
  this.host = host || config.host
68
73
  this.port = typeof port === "number" ? port : config.port
@@ -123,6 +128,8 @@ export default class BackgroundJobsMain {
123
128
  this._draining = false
124
129
  this._redrainQueued = false
125
130
  this._stopped = false
131
+ /** @type {Promise<void> | undefined} */
132
+ this.stopPromise = undefined
126
133
  /**
127
134
  * Narrows the runtime value to the documented type.
128
135
  * @type {(() => void) | undefined} */
@@ -143,6 +150,7 @@ export default class BackgroundJobsMain {
143
150
  */
144
151
  async start() {
145
152
  this._stopped = false
153
+ this.stopPromise = undefined
146
154
  this.configuration.setCurrent()
147
155
  await this.configuration.initialize({type: "background-jobs-main"})
148
156
  await this.configuration.connectBeacon({peerType: "background-jobs-main"})
@@ -214,18 +222,33 @@ export default class BackgroundJobsMain {
214
222
  * Runs stop.
215
223
  * @returns {Promise<void>} - Resolves when closed.
216
224
  */
217
- async stop() {
225
+ stop() {
226
+ if (!this.stopPromise) this.stopPromise = this._stop()
227
+
228
+ return this.stopPromise
229
+ }
230
+
231
+ /**
232
+ * Runs the main-process shutdown lifecycle once.
233
+ * @returns {Promise<void>} - Resolves when closed.
234
+ */
235
+ async _stop() {
218
236
  this._stopped = true
219
237
 
220
- this._closeWorkers()
221
- this._clearTimers()
222
- this._disconnectBeaconHandlers()
223
- await this.scheduler?.stop()
224
- try {
225
- await this._drainWorkerHandoffAdoptions()
226
- } finally {
227
- await this._stopBeaconAndServer()
228
- }
238
+ await shutdownLifecycle({
239
+ onStopped: this.onStopped,
240
+ shutdown: async () => {
241
+ this._closeWorkers()
242
+ this._clearTimers()
243
+ this._disconnectBeaconHandlers()
244
+ await this.scheduler?.stop()
245
+ try {
246
+ await this._drainWorkerHandoffAdoptions()
247
+ } finally {
248
+ await this._stopBeaconAndServer()
249
+ }
250
+ }
251
+ })
229
252
  }
230
253
 
231
254
  /**
@@ -287,7 +310,7 @@ export default class BackgroundJobsMain {
287
310
  try {
288
311
  await this._closeServer()
289
312
  } finally {
290
- await this.configuration.closeDatabaseConnections()
313
+ if (this.closeDatabaseConnectionsOnStop) await this.configuration.closeDatabaseConnections()
291
314
  }
292
315
  }
293
316
 
@@ -8,6 +8,7 @@ import configurationResolver from "../configuration-resolver.js"
8
8
  import BackgroundJobsStatusReporter from "./status-reporter.js"
9
9
  import {randomUUID} from "crypto"
10
10
  import {fileURLToPath} from "node:url"
11
+ import shutdownLifecycle from "../utils/shutdown-lifecycle.js"
11
12
 
12
13
  /** Grace period after SIGTERM before a lingering process runner is SIGKILLed. */
13
14
  const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
@@ -73,8 +74,10 @@ export default class BackgroundJobsWorker {
73
74
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
74
75
  * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
75
76
  * @param {number} [args.jobTimeoutMs] - Override the wall-clock timeout for forked and pooled jobs from `configuration.getBackgroundJobsConfig()`. `0` disables it.
77
+ * @param {boolean} [args.closeDatabaseConnectionsOnStop] - Whether stop owns closing the configuration's database pools (default true).
78
+ * @param {() => void | Promise<void>} [args.onStopped] - Lifecycle hook invoked after the worker finishes stopping.
76
79
  */
77
- constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
80
+ constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs, closeDatabaseConnectionsOnStop = true, onStopped} = {}) {
78
81
  /**
79
82
  * Narrows the runtime value to the documented type.
80
83
  * @type {Promise<import("../configuration.js").default>} */
@@ -85,6 +88,8 @@ export default class BackgroundJobsWorker {
85
88
  this.configuration = undefined
86
89
  this.host = host
87
90
  this.port = port
91
+ this.closeDatabaseConnectionsOnStop = closeDatabaseConnectionsOnStop
92
+ this.onStopped = onStopped
88
93
  /**
89
94
  * Constructor override for the inline-job concurrency cap. When unset
90
95
  * the cap is read from `configuration.getBackgroundJobsConfig()` in
@@ -136,6 +141,8 @@ export default class BackgroundJobsWorker {
136
141
  */
137
142
  this.jobTimeoutMsOverride = typeof jobTimeoutMs === "number" ? jobTimeoutMs : undefined
138
143
  this.shouldStop = false
144
+ /** @type {Promise<void> | undefined} */
145
+ this.stopPromise = undefined
139
146
  this.workerId = randomUUID()
140
147
  this.heartbeatIntervalMs = typeof heartbeatIntervalMs === "number" && heartbeatIntervalMs >= 1
141
148
  ? heartbeatIntervalMs
@@ -195,6 +202,8 @@ export default class BackgroundJobsWorker {
195
202
  * @returns {Promise<void>} - Resolves when connected.
196
203
  */
197
204
  async start() {
205
+ this.shouldStop = false
206
+ this.stopPromise = undefined
198
207
  this.configuration = await this.configurationPromise
199
208
  this.configuration.setCurrent()
200
209
  await this.configuration.initialize({type: "background-jobs-worker"})
@@ -241,37 +250,53 @@ export default class BackgroundJobsWorker {
241
250
  * @param {number} [args.timeoutMs] - Max wait for in-flight jobs (per phase) in ms.
242
251
  * @returns {Promise<void>} - Resolves when stopped.
243
252
  */
244
- async stop({timeoutMs} = {}) {
245
- if (this.shouldStop) return
246
- this.shouldStop = true
247
- this._stopHeartbeat()
253
+ stop({timeoutMs} = {}) {
254
+ if (!this.stopPromise) this.stopPromise = this._stop({timeoutMs})
248
255
 
249
- // Announce drain so main stops dispatching but keeps the connection
250
- // open until we close it ourselves below.
251
- if (this.jsonSocket) {
252
- try {
253
- this.jsonSocket.send({type: "draining"})
254
- } catch {
255
- // Socket may already be closing; nothing to do.
256
- }
257
- }
256
+ return this.stopPromise
257
+ }
258
258
 
259
- await this._drainInflight(this.inflightInlineJobs, timeoutMs)
260
- await this._drainInflight(this.inflightPooledJobs, timeoutMs)
261
- await this._drainInflight(this.inflightProcessJobs, timeoutMs)
262
- await this._terminateProcessChildren()
263
- // Give in-flight result reports (now decoupled from job slots) a bounded
264
- // chance to land before the socket closes.
265
- await this._drainInflight(this.inflightReports, timeoutMs)
259
+ /**
260
+ * Runs the worker shutdown lifecycle once.
261
+ * @param {object} [args] - Options.
262
+ * @param {number} [args.timeoutMs] - Max wait for in-flight jobs (per phase) in ms.
263
+ * @returns {Promise<void>} - Resolves when stopped.
264
+ */
265
+ async _stop({timeoutMs} = {}) {
266
+ this.shouldStop = true
267
+ this._stopHeartbeat()
266
268
 
267
- if (this.jsonSocket) this.jsonSocket.close()
268
- if (this.configuration) {
269
- try {
270
- await this.configuration.disconnectBeacon()
271
- } finally {
272
- await this.configuration.closeDatabaseConnections()
269
+ await shutdownLifecycle({
270
+ onStopped: this.onStopped,
271
+ shutdown: async () => {
272
+ // Announce drain so main stops dispatching but keeps the connection
273
+ // open until we close it ourselves below.
274
+ if (this.jsonSocket) {
275
+ try {
276
+ this.jsonSocket.send({type: "draining"})
277
+ } catch {
278
+ // Socket may already be closing; nothing to do.
279
+ }
280
+ }
281
+
282
+ await this._drainInflight(this.inflightInlineJobs, timeoutMs)
283
+ await this._drainInflight(this.inflightPooledJobs, timeoutMs)
284
+ await this._drainInflight(this.inflightProcessJobs, timeoutMs)
285
+ await this._terminateProcessChildren()
286
+ // Give in-flight result reports (now decoupled from job slots) a bounded
287
+ // chance to land before the socket closes.
288
+ await this._drainInflight(this.inflightReports, timeoutMs)
289
+
290
+ if (this.jsonSocket) this.jsonSocket.close()
291
+ if (!this.configuration) return
292
+
293
+ try {
294
+ await this.configuration.disconnectBeacon()
295
+ } finally {
296
+ if (this.closeDatabaseConnectionsOnStop) await this.configuration.closeDatabaseConnections()
297
+ }
273
298
  }
274
- }
299
+ })
275
300
  }
276
301
 
277
302
  /**
@@ -1710,6 +1710,12 @@ class VelociousDatabaseRecord {
1710
1710
  */
1711
1711
  static switchesTenantDatabase(databaseIdentifierOrResolver) {
1712
1712
  this._tenantDatabaseIdentifierResolver = databaseIdentifierOrResolver
1713
+
1714
+ if (this._translationClass) {
1715
+ const translatedModelClass = this
1716
+
1717
+ this._translationClass.switchesTenantDatabase(({tenant}) => translatedModelClass.getTenantDatabaseIdentifier(tenant))
1718
+ }
1713
1719
  }
1714
1720
 
1715
1721
  /**
@@ -2814,6 +2820,12 @@ class VelociousDatabaseRecord {
2814
2820
  TranslationClass.setTableName(this.getTranslationsTableName())
2815
2821
  TranslationClass.belongsTo(belongsTo)
2816
2822
 
2823
+ if (this.hasTenantDatabaseIdentifierResolver()) {
2824
+ const translatedModelClass = this
2825
+
2826
+ TranslationClass.switchesTenantDatabase(({tenant}) => translatedModelClass.getTenantDatabaseIdentifier(tenant))
2827
+ }
2828
+
2817
2829
  this._translationClass = TranslationClass
2818
2830
 
2819
2831
  return this._translationClass