velocious 1.0.714 → 1.0.716

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 (59) hide show
  1. package/build/background-jobs/local-store.js +3 -0
  2. package/build/background-jobs/store.js +77 -1
  3. package/build/background-jobs/sync-replay-job.js +79 -0
  4. package/build/background-jobs/types.js +2 -0
  5. package/build/database/drivers/base.js +60 -0
  6. package/build/database/drivers/mssql/index.js +59 -0
  7. package/build/database/drivers/mysql/index.js +51 -0
  8. package/build/database/drivers/pgsql/index.js +61 -0
  9. package/build/database/initializer-from-require-context.js +31 -7
  10. package/build/database/schema-metadata-prefetcher.js +108 -0
  11. package/build/src/background-jobs/local-store.d.ts.map +1 -1
  12. package/build/src/background-jobs/local-store.js +4 -1
  13. package/build/src/background-jobs/store.d.ts +24 -0
  14. package/build/src/background-jobs/store.d.ts.map +1 -1
  15. package/build/src/background-jobs/store.js +66 -2
  16. package/build/src/background-jobs/sync-replay-job.d.ts +45 -0
  17. package/build/src/background-jobs/sync-replay-job.d.ts.map +1 -0
  18. package/build/src/background-jobs/sync-replay-job.js +73 -0
  19. package/build/src/background-jobs/types.d.ts +10 -0
  20. package/build/src/background-jobs/types.d.ts.map +1 -1
  21. package/build/src/background-jobs/types.js +3 -1
  22. package/build/src/database/drivers/base.d.ts +29 -0
  23. package/build/src/database/drivers/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/base.js +55 -1
  25. package/build/src/database/drivers/mssql/index.d.ts +9 -0
  26. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/mssql/index.js +54 -1
  28. package/build/src/database/drivers/mysql/index.d.ts +13 -0
  29. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  30. package/build/src/database/drivers/mysql/index.js +46 -1
  31. package/build/src/database/drivers/pgsql/index.d.ts +12 -2
  32. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  33. package/build/src/database/drivers/pgsql/index.js +56 -1
  34. package/build/src/database/initializer-from-require-context.d.ts.map +1 -1
  35. package/build/src/database/initializer-from-require-context.js +26 -7
  36. package/build/src/database/schema-metadata-prefetcher.d.ts +53 -0
  37. package/build/src/database/schema-metadata-prefetcher.d.ts.map +1 -0
  38. package/build/src/database/schema-metadata-prefetcher.js +95 -0
  39. package/build/src/sync/sync-client-registry.d.ts +9 -0
  40. package/build/src/sync/sync-client-registry.d.ts.map +1 -1
  41. package/build/src/sync/sync-client-registry.js +12 -1
  42. package/build/src/utils/bounded-task-runner.d.ts +26 -0
  43. package/build/src/utils/bounded-task-runner.d.ts.map +1 -0
  44. package/build/src/utils/bounded-task-runner.js +44 -0
  45. package/build/sync/sync-client-registry.js +12 -0
  46. package/build/utils/bounded-task-runner.js +51 -0
  47. package/package.json +1 -1
  48. package/src/background-jobs/local-store.js +3 -0
  49. package/src/background-jobs/store.js +77 -1
  50. package/src/background-jobs/sync-replay-job.js +79 -0
  51. package/src/background-jobs/types.js +2 -0
  52. package/src/database/drivers/base.js +60 -0
  53. package/src/database/drivers/mssql/index.js +59 -0
  54. package/src/database/drivers/mysql/index.js +51 -0
  55. package/src/database/drivers/pgsql/index.js +61 -0
  56. package/src/database/initializer-from-require-context.js +31 -7
  57. package/src/database/schema-metadata-prefetcher.js +108 -0
  58. package/src/sync/sync-client-registry.js +12 -0
  59. package/src/utils/bounded-task-runner.js +51 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Runs one callback per item with a bounded number of callbacks in flight.
3
+ *
4
+ * Used where a startup pass has to touch many independent records (model
5
+ * metadata, per-table schema reads) and running them sequentially multiplies the
6
+ * round-trip latency by the item count, while running them all at once would
7
+ * open an unbounded number of database connections.
8
+ */
9
+ export default class BoundedTaskRunner {
10
+ /**
11
+ * Runs the worker for every item, keeping at most `concurrency` callbacks in
12
+ * flight and returning the results in input order.
13
+ * @template T, R
14
+ * @param {object} args - Run args.
15
+ * @param {number} [args.concurrency] - Maximum callbacks in flight.
16
+ * @param {T[]} args.items - Items to process.
17
+ * @param {(item: T, index: number) => Promise<R>} args.worker - Callback per item.
18
+ * @returns {Promise<R[]>} - Results in input order.
19
+ */
20
+ static run<T, R>({ concurrency, items, worker }: {
21
+ concurrency?: number;
22
+ items: T[];
23
+ worker: (item: T, index: number) => Promise<R>;
24
+ }): Promise<R[]>;
25
+ }
26
+ //# sourceMappingURL=bounded-task-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded-task-runner.d.ts","sourceRoot":"","sources":["../../../src/utils/bounded-task-runner.js"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAiB;IACpC;;;;;;;;;OASG;IACH,OAAa,GAAG,CAPH,CAAC,EAAE,CAAC,EAOA,EAAC,WAAe,EAAE,KAAK,EAAE,MAAM,EAAC,EAL9C;QAAsB,WAAW,AAAjC,CACA,EADQ,MAAM,CACd;QAAkB,KAAK,EAAf,CAAC,EAAE,CACX;QAAqD,MAAM,EAAnD,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAC9C;KAE8C,GAFpC,OAAO,CAAC,CAAC,EAAE,CAAC,CA8BxB;CACF"}
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ /**
3
+ * Runs one callback per item with a bounded number of callbacks in flight.
4
+ *
5
+ * Used where a startup pass has to touch many independent records (model
6
+ * metadata, per-table schema reads) and running them sequentially multiplies the
7
+ * round-trip latency by the item count, while running them all at once would
8
+ * open an unbounded number of database connections.
9
+ */
10
+ export default class BoundedTaskRunner {
11
+ /**
12
+ * Runs the worker for every item, keeping at most `concurrency` callbacks in
13
+ * flight and returning the results in input order.
14
+ * @template T, R
15
+ * @param {object} args - Run args.
16
+ * @param {number} [args.concurrency] - Maximum callbacks in flight.
17
+ * @param {T[]} args.items - Items to process.
18
+ * @param {(item: T, index: number) => Promise<R>} args.worker - Callback per item.
19
+ * @returns {Promise<R[]>} - Results in input order.
20
+ */
21
+ static async run({ concurrency = 8, items, worker }) {
22
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
23
+ throw new Error(`Concurrency must be a positive integer but got ${String(concurrency)}`);
24
+ }
25
+ /** @type {R[]} */
26
+ const results = new Array(items.length);
27
+ let nextIndex = 0;
28
+ const runNext = async () => {
29
+ while (nextIndex < items.length) {
30
+ const index = nextIndex;
31
+ nextIndex += 1;
32
+ results[index] = await worker(items[index], index);
33
+ }
34
+ };
35
+ /** @type {Promise<void>[]} */
36
+ const runners = [];
37
+ for (let index = 0; index < Math.min(concurrency, items.length); index++) {
38
+ runners.push(runNext());
39
+ }
40
+ await Promise.all(runners);
41
+ return results;
42
+ }
43
+ }
44
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYm91bmRlZC10YXNrLXJ1bm5lci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy91dGlscy9ib3VuZGVkLXRhc2stcnVubmVyLmpzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLFlBQVk7QUFFWjs7Ozs7OztHQU9HO0FBQ0gsTUFBTSxDQUFDLE9BQU8sT0FBTyxpQkFBaUI7SUFDcEM7Ozs7Ozs7OztPQVNHO0lBQ0gsTUFBTSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsRUFBQyxXQUFXLEdBQUcsQ0FBQyxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUM7UUFDL0MsSUFBSSxDQUFDLE1BQU0sQ0FBQyxTQUFTLENBQUMsV0FBVyxDQUFDLElBQUksV0FBVyxHQUFHLENBQUMsRUFBRSxDQUFDO1lBQ3RELE1BQU0sSUFBSSxLQUFLLENBQUMsa0RBQWtELE1BQU0sQ0FBQyxXQUFXLENBQUMsRUFBRSxDQUFDLENBQUE7UUFDMUYsQ0FBQztRQUVELGtCQUFrQjtRQUNsQixNQUFNLE9BQU8sR0FBRyxJQUFJLEtBQUssQ0FBQyxLQUFLLENBQUMsTUFBTSxDQUFDLENBQUE7UUFDdkMsSUFBSSxTQUFTLEdBQUcsQ0FBQyxDQUFBO1FBRWpCLE1BQU0sT0FBTyxHQUFHLEtBQUssSUFBSSxFQUFFO1lBQ3pCLE9BQU8sU0FBUyxHQUFHLEtBQUssQ0FBQyxNQUFNLEVBQUUsQ0FBQztnQkFDaEMsTUFBTSxLQUFLLEdBQUcsU0FBUyxDQUFBO2dCQUV2QixTQUFTLElBQUksQ0FBQyxDQUFBO2dCQUNkLE9BQU8sQ0FBQyxLQUFLLENBQUMsR0FBRyxNQUFNLE1BQU0sQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLEVBQUUsS0FBSyxDQUFDLENBQUE7WUFDcEQsQ0FBQztRQUNILENBQUMsQ0FBQTtRQUVELDhCQUE4QjtRQUM5QixNQUFNLE9BQU8sR0FBRyxFQUFFLENBQUE7UUFFbEIsS0FBSyxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsV0FBVyxFQUFFLEtBQUssQ0FBQyxNQUFNLENBQUMsRUFBRSxLQUFLLEVBQUUsRUFBRSxDQUFDO1lBQ3pFLE9BQU8sQ0FBQyxJQUFJLENBQUMsT0FBTyxFQUFFLENBQUMsQ0FBQTtRQUN6QixDQUFDO1FBRUQsTUFBTSxPQUFPLENBQUMsR0FBRyxDQUFDLE9BQU8sQ0FBQyxDQUFBO1FBRTFCLE9BQU8sT0FBTyxDQUFBO0lBQ2hCLENBQUM7Q0FDRiIsInNvdXJjZXNDb250ZW50IjpbIi8vIEB0cy1jaGVja1xuXG4vKipcbiAqIFJ1bnMgb25lIGNhbGxiYWNrIHBlciBpdGVtIHdpdGggYSBib3VuZGVkIG51bWJlciBvZiBjYWxsYmFja3MgaW4gZmxpZ2h0LlxuICpcbiAqIFVzZWQgd2hlcmUgYSBzdGFydHVwIHBhc3MgaGFzIHRvIHRvdWNoIG1hbnkgaW5kZXBlbmRlbnQgcmVjb3JkcyAobW9kZWxcbiAqIG1ldGFkYXRhLCBwZXItdGFibGUgc2NoZW1hIHJlYWRzKSBhbmQgcnVubmluZyB0aGVtIHNlcXVlbnRpYWxseSBtdWx0aXBsaWVzIHRoZVxuICogcm91bmQtdHJpcCBsYXRlbmN5IGJ5IHRoZSBpdGVtIGNvdW50LCB3aGlsZSBydW5uaW5nIHRoZW0gYWxsIGF0IG9uY2Ugd291bGRcbiAqIG9wZW4gYW4gdW5ib3VuZGVkIG51bWJlciBvZiBkYXRhYmFzZSBjb25uZWN0aW9ucy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgY2xhc3MgQm91bmRlZFRhc2tSdW5uZXIge1xuICAvKipcbiAgICogUnVucyB0aGUgd29ya2VyIGZvciBldmVyeSBpdGVtLCBrZWVwaW5nIGF0IG1vc3QgYGNvbmN1cnJlbmN5YCBjYWxsYmFja3MgaW5cbiAgICogZmxpZ2h0IGFuZCByZXR1cm5pbmcgdGhlIHJlc3VsdHMgaW4gaW5wdXQgb3JkZXIuXG4gICAqIEB0ZW1wbGF0ZSBULCBSXG4gICAqIEBwYXJhbSB7b2JqZWN0fSBhcmdzIC0gUnVuIGFyZ3MuXG4gICAqIEBwYXJhbSB7bnVtYmVyfSBbYXJncy5jb25jdXJyZW5jeV0gLSBNYXhpbXVtIGNhbGxiYWNrcyBpbiBmbGlnaHQuXG4gICAqIEBwYXJhbSB7VFtdfSBhcmdzLml0ZW1zIC0gSXRlbXMgdG8gcHJvY2Vzcy5cbiAgICogQHBhcmFtIHsoaXRlbTogVCwgaW5kZXg6IG51bWJlcikgPT4gUHJvbWlzZTxSPn0gYXJncy53b3JrZXIgLSBDYWxsYmFjayBwZXIgaXRlbS5cbiAgICogQHJldHVybnMge1Byb21pc2U8UltdPn0gLSBSZXN1bHRzIGluIGlucHV0IG9yZGVyLlxuICAgKi9cbiAgc3RhdGljIGFzeW5jIHJ1bih7Y29uY3VycmVuY3kgPSA4LCBpdGVtcywgd29ya2VyfSkge1xuICAgIGlmICghTnVtYmVyLmlzSW50ZWdlcihjb25jdXJyZW5jeSkgfHwgY29uY3VycmVuY3kgPCAxKSB7XG4gICAgICB0aHJvdyBuZXcgRXJyb3IoYENvbmN1cnJlbmN5IG11c3QgYmUgYSBwb3NpdGl2ZSBpbnRlZ2VyIGJ1dCBnb3QgJHtTdHJpbmcoY29uY3VycmVuY3kpfWApXG4gICAgfVxuXG4gICAgLyoqIEB0eXBlIHtSW119ICovXG4gICAgY29uc3QgcmVzdWx0cyA9IG5ldyBBcnJheShpdGVtcy5sZW5ndGgpXG4gICAgbGV0IG5leHRJbmRleCA9IDBcblxuICAgIGNvbnN0IHJ1bk5leHQgPSBhc3luYyAoKSA9PiB7XG4gICAgICB3aGlsZSAobmV4dEluZGV4IDwgaXRlbXMubGVuZ3RoKSB7XG4gICAgICAgIGNvbnN0IGluZGV4ID0gbmV4dEluZGV4XG5cbiAgICAgICAgbmV4dEluZGV4ICs9IDFcbiAgICAgICAgcmVzdWx0c1tpbmRleF0gPSBhd2FpdCB3b3JrZXIoaXRlbXNbaW5kZXhdLCBpbmRleClcbiAgICAgIH1cbiAgICB9XG5cbiAgICAvKiogQHR5cGUge1Byb21pc2U8dm9pZD5bXX0gKi9cbiAgICBjb25zdCBydW5uZXJzID0gW11cblxuICAgIGZvciAobGV0IGluZGV4ID0gMDsgaW5kZXggPCBNYXRoLm1pbihjb25jdXJyZW5jeSwgaXRlbXMubGVuZ3RoKTsgaW5kZXgrKykge1xuICAgICAgcnVubmVycy5wdXNoKHJ1bk5leHQoKSlcbiAgICB9XG5cbiAgICBhd2FpdCBQcm9taXNlLmFsbChydW5uZXJzKVxuXG4gICAgcmV0dXJuIHJlc3VsdHNcbiAgfVxufVxuIl19
@@ -28,3 +28,15 @@ export function currentSyncClient() {
28
28
 
29
29
  return currentClient
30
30
  }
31
+
32
+ /**
33
+ * Returns the current sync client when one is registered.
34
+ *
35
+ * Background jobs that replay the sync queue can legitimately run before an app has
36
+ * registered its client, or after it cleared it on sign-out. They need a non-throwing
37
+ * lookup so that state is a no-op instead of a failed job.
38
+ * @returns {ReturnType<typeof JSON.parse> | null} Current sync client, or null.
39
+ */
40
+ export function currentSyncClientOrNull() {
41
+ return currentClient
42
+ }
@@ -0,0 +1,51 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Runs one callback per item with a bounded number of callbacks in flight.
5
+ *
6
+ * Used where a startup pass has to touch many independent records (model
7
+ * metadata, per-table schema reads) and running them sequentially multiplies the
8
+ * round-trip latency by the item count, while running them all at once would
9
+ * open an unbounded number of database connections.
10
+ */
11
+ export default class BoundedTaskRunner {
12
+ /**
13
+ * Runs the worker for every item, keeping at most `concurrency` callbacks in
14
+ * flight and returning the results in input order.
15
+ * @template T, R
16
+ * @param {object} args - Run args.
17
+ * @param {number} [args.concurrency] - Maximum callbacks in flight.
18
+ * @param {T[]} args.items - Items to process.
19
+ * @param {(item: T, index: number) => Promise<R>} args.worker - Callback per item.
20
+ * @returns {Promise<R[]>} - Results in input order.
21
+ */
22
+ static async run({concurrency = 8, items, worker}) {
23
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
24
+ throw new Error(`Concurrency must be a positive integer but got ${String(concurrency)}`)
25
+ }
26
+
27
+ /** @type {R[]} */
28
+ const results = new Array(items.length)
29
+ let nextIndex = 0
30
+
31
+ const runNext = async () => {
32
+ while (nextIndex < items.length) {
33
+ const index = nextIndex
34
+
35
+ nextIndex += 1
36
+ results[index] = await worker(items[index], index)
37
+ }
38
+ }
39
+
40
+ /** @type {Promise<void>[]} */
41
+ const runners = []
42
+
43
+ for (let index = 0; index < Math.min(concurrency, items.length); index++) {
44
+ runners.push(runNext())
45
+ }
46
+
47
+ await Promise.all(runners)
48
+
49
+ return results
50
+ }
51
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.714",
6
+ "version": "1.0.716",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -1598,6 +1598,9 @@ export default class LocalBackgroundJobsStore {
1598
1598
  failedAtMs: this._numberOrNull(row.failed_at_ms),
1599
1599
  handedOffAtMs: this._numberOrNull(row.handed_off_at_ms),
1600
1600
  handoffId: row.handoff_id === null || row.handoff_id === undefined ? null : String(row.handoff_id),
1601
+ // Local (browser/Expo) dispatch is strictly FIFO and has no priority column,
1602
+ // so every row reports the default priority.
1603
+ priority: 0,
1601
1604
  id: String(row.id),
1602
1605
  jobName: String(row.job_name),
1603
1606
  lastError: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
@@ -41,6 +41,7 @@ import {
41
41
  * @property {string} jobId - New job id.
42
42
  * @property {string} jobName - Job name.
43
43
  * @property {number} maxRetries - Retry cap.
44
+ * @property {number} priority - Dispatch priority within the queue.
44
45
  * @property {string} queue - Queue name.
45
46
  * @property {number} scheduledAtMs - Eligibility timestamp.
46
47
  * @property {number | null} timeoutMs - Per-job timeout override, or null when omitted.
@@ -137,8 +138,22 @@ export const BACKGROUND_JOB_COUNT_BUCKETS = ["all", "queued", "handed_off", "com
137
138
  const COUNTED_JOB_STATUSES = BACKGROUND_JOB_COUNT_BUCKETS.slice(1)
138
139
  const MAX_JOB_TIMEOUT_MS = 2_147_483_647
139
140
  const JOB_TIMEOUT_VALIDATION_MESSAGE = `background job timeoutMs must be a finite non-positive number or an integer between 1 and ${MAX_JOB_TIMEOUT_MS}`
141
+ const BACKGROUND_JOB_PRIORITY_VALIDATION_MESSAGE = "background job priority must be a non-negative integer"
140
142
  const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
141
143
 
144
+ /**
145
+ * Priority a job is demoted to when it is re-queued after a failed attempt.
146
+ * A retry has already consumed capacity and time, so it must never overtake
147
+ * work that has not had its first chance yet. Enqueue priorities are validated
148
+ * to be non-negative integers, which makes `-1` the lowest reachable priority.
149
+ */
150
+ export const RETRY_BACKGROUND_JOB_PRIORITY = -1
151
+
152
+ /**
153
+ * Priority a job gets when the producer does not ask for one.
154
+ */
155
+ export const DEFAULT_BACKGROUND_JOB_PRIORITY = 0
156
+
142
157
  /**
143
158
  * Columns the dashboard is allowed to sort job listings by, mapped to their
144
159
  * database column names. Restricting to this set keeps the sort parameter
@@ -1159,6 +1174,12 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1159
1174
  const priorityOrder = this._queuePriorityOrderSql(db)
1160
1175
 
1161
1176
  if (priorityOrder) query = query.order(`${priorityOrder} DESC`)
1177
+
1178
+ // Job-level priority applies inside the queue ranking above. A retry is
1179
+ // stored at RETRY_BACKGROUND_JOB_PRIORITY (-1), so it sorts behind every
1180
+ // first attempt without delaying itself further than the backoff already
1181
+ // does. COALESCE covers rows written before the column existed.
1182
+ query = query.order(`COALESCE(${db.quoteColumn("priority")}, ${DEFAULT_BACKGROUND_JOB_PRIORITY}) DESC`)
1162
1183
  }
1163
1184
 
1164
1185
  query = query
@@ -1965,12 +1986,33 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1965
1986
  jobId: randomUUID(),
1966
1987
  jobName,
1967
1988
  maxRetries: this._normalizeMaxRetries(options?.maxRetries),
1989
+ priority: this._normalizeJobPriority(options),
1968
1990
  queue,
1969
1991
  scheduledAtMs: this._normalizeScheduledAtMs(options?.scheduledAtMs, createdAtMs),
1970
1992
  timeoutMs: this._normalizeJobTimeoutMs(options)
1971
1993
  }
1972
1994
  }
1973
1995
 
1996
+ /**
1997
+ * Normalizes a requested dispatch priority. Priorities order jobs within their
1998
+ * queue on the dispatch path only: higher goes first, and the default keeps
1999
+ * FIFO order. Negative priorities are rejected because the store reserves them
2000
+ * for {@link RETRY_BACKGROUND_JOB_PRIORITY}, which must stay the lowest.
2001
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
2002
+ * @returns {number} - Priority.
2003
+ */
2004
+ _normalizeJobPriority(options) {
2005
+ const priority = options?.priority
2006
+
2007
+ if (priority === undefined) return DEFAULT_BACKGROUND_JOB_PRIORITY
2008
+
2009
+ if (typeof priority !== "number" || !Number.isInteger(priority) || priority < 0) {
2010
+ throw VelociousError.safe(BACKGROUND_JOB_PRIORITY_VALIDATION_MESSAGE)
2011
+ }
2012
+
2013
+ return priority
2014
+ }
2015
+
1974
2016
  /**
1975
2017
  * Normalizes a per-job timeout while preserving omitted (worker fallback)
1976
2018
  * separately from explicitly disabled.
@@ -2024,6 +2066,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2024
2066
  execution_mode: preparedJob.executionMode,
2025
2067
  queue: preparedJob.queue,
2026
2068
  max_retries: preparedJob.maxRetries,
2069
+ priority: preparedJob.priority,
2027
2070
  attempts: 0,
2028
2071
  status: "queued",
2029
2072
  scheduled_at_ms: preparedJob.scheduledAtMs,
@@ -2334,6 +2377,30 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2334
2377
  }
2335
2378
  }
2336
2379
 
2380
+ const priorityLockName = `${MIGRATION_SCOPE}:priority_column`
2381
+ const priorityLockAcquired = await db.acquireAdvisoryLock(priorityLockName)
2382
+
2383
+ if (!priorityLockAcquired) throw new Error("Failed to acquire background jobs priority schema lock")
2384
+
2385
+ try {
2386
+ db.clearSchemaCache()
2387
+ const priorityTable = await db.getTableByNameOrFail(JOBS_TABLE)
2388
+
2389
+ if (!(await priorityTable.getColumnByName("priority"))) {
2390
+ const tableData = new TableData(JOBS_TABLE)
2391
+ tableData.bigint("priority", {null: true})
2392
+ const sqls = await db.alterTableSQLs(tableData)
2393
+
2394
+ for (const sql of sqls) {
2395
+ await db.query(sql)
2396
+ }
2397
+
2398
+ db.clearSchemaCache()
2399
+ }
2400
+ } finally {
2401
+ await db.releaseAdvisoryLock(priorityLockName)
2402
+ }
2403
+
2337
2404
  await this._backfillExecutionModesOnce(db)
2338
2405
  await this._dropForkedColumnOnce(db)
2339
2406
 
@@ -3109,6 +3176,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
3109
3176
 
3110
3177
  if (markOrphaned) transitionedJob.orphanedAtMs = now
3111
3178
  if (shouldRetry) {
3179
+ transitionedJob.priority = RETRY_BACKGROUND_JOB_PRIORITY
3112
3180
  transitionedJob.scheduledAtMs = scheduledAt
3113
3181
  } else if (!markOrphaned) {
3114
3182
  transitionedJob.failedAtMs = now
@@ -3142,7 +3210,14 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
3142
3210
  // A retry starts a fresh handoff with a possibly different runner, so the
3143
3211
  // previous child's acceptance evidence must not leak into the next attempt.
3144
3212
  // Terminal failures keep it as historical evidence for the lost attempt.
3145
- if (shouldRetry) Object.assign(update, this._clearedChildAcceptanceData())
3213
+ if (shouldRetry) {
3214
+ Object.assign(update, this._clearedChildAcceptanceData())
3215
+
3216
+ // Retried work goes to the back of the dispatch order. It has already had
3217
+ // a turn, and letting it overtake first attempts is how a failing producer
3218
+ // starves healthy jobs of the same queue.
3219
+ update.priority = RETRY_BACKGROUND_JOB_PRIORITY
3220
+ }
3146
3221
 
3147
3222
  this._applyOrphanedFailureUpdate({markOrphaned, now, update})
3148
3223
  this._applyFailureStatusUpdate({markOrphaned, now, scheduledAt, shouldRetry, update})
@@ -3211,6 +3286,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
3211
3286
  status: normalizeBackgroundJobStatus(row.status ? String(row.status) : "queued"),
3212
3287
  attempts: this._normalizeNumber(row.attempts),
3213
3288
  maxRetries: this._normalizeNumber(row.max_retries),
3289
+ priority: this._normalizeNumber(row.priority) ?? DEFAULT_BACKGROUND_JOB_PRIORITY,
3214
3290
  scheduledAtMs: this._normalizeNumber(row.scheduled_at_ms),
3215
3291
  createdAtMs: this._normalizeNumber(row.created_at_ms),
3216
3292
  handedOffAtMs: this._normalizeNumber(row.handed_off_at_ms),
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+
3
+ import Logger from "../logger.js"
4
+ import VelociousJob from "./platform-job.js"
5
+ import {currentSyncClientOrNull} from "../sync/sync-client-registry.js"
6
+
7
+ const logger = new Logger("SyncReplayJob")
8
+
9
+ /**
10
+ * Durable, deduplicated replay of the device's pending sync queue.
11
+ *
12
+ * Offline-capable apps write local mutations with `syncClient.queue()`, which
13
+ * schedules an in-process replay attempt. That attempt lives only as long as the
14
+ * JavaScript runtime, so a scan (or any other mutation) that happens while the app
15
+ * is closing, backgrounding, or briefly online can leave rows pending. Enqueueing
16
+ * this job after such a mutation makes the replay durable: the local background-jobs
17
+ * adapter stores the trigger in SQLite, runs it in process as soon as the app is
18
+ * active, and collapses overlapping triggers onto one queued job.
19
+ *
20
+ * `perform()` is deliberately best-effort. Pending rows are only deleted after the
21
+ * backend acknowledges them, so a failed or offline replay leaves the queue intact
22
+ * for the next enqueue (next mutation, focus, or foreground trigger) instead of
23
+ * consuming the job's retries on a device that may stay offline for hours.
24
+ *
25
+ * Apps register it next to their other jobs and enqueue it after local mutations:
26
+ *
27
+ * ```js
28
+ * backgroundJobs: {mode: "background", jobClasses: [SyncReplayJob]}
29
+ *
30
+ * await SyncReplayJob.enqueue()
31
+ * ```
32
+ * @augments {VelociousJob}
33
+ */
34
+ export default class SyncReplayJob extends VelociousJob {
35
+ /**
36
+ * Returns the stable job name used by the persisted queue.
37
+ * @returns {string} - Job name.
38
+ */
39
+ static jobName() { return "syncReplay" }
40
+
41
+ /**
42
+ * Enqueues one deduplicated replay. Resolves after the durable enqueue, not after
43
+ * the replay finishes, so callers can stay on the synchronous local path.
44
+ * @returns {Promise<string>} - Queued job id.
45
+ */
46
+ static async enqueue() {
47
+ return await this.performLaterWithOptions({
48
+ args: [],
49
+ options: {
50
+ concurrencyKey: "sync-replay",
51
+ deduplicateWhileQueued: true,
52
+ maxConcurrency: 1
53
+ }
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Drains the pending sync queue through the app's current sync client.
59
+ * @returns {Promise<void>}
60
+ */
61
+ async perform() {
62
+ const syncClient = currentSyncClientOrNull()
63
+
64
+ if (!syncClient) {
65
+ void logger.debug("sync replay skipped because no sync client is registered")
66
+
67
+ return
68
+ }
69
+
70
+ try {
71
+ await syncClient.replayPending()
72
+ } catch (error) {
73
+ // Pending rows survive a failed or offline replay, so the next enqueue retries
74
+ // them. Re-throwing would make the adapter retry a device that may be offline
75
+ // for hours and mark healthy offline periods as job failures.
76
+ void logger.error(() => ["sync replay failed (pending rows stay queued)", error])
77
+ }
78
+ }
79
+ }
@@ -169,6 +169,7 @@
169
169
  * @typedef {object} BackgroundJobOptions
170
170
  * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Node defaults to `"pooled"` (a warm, reused local runner process). Browser/Expo local dispatch defaults to and only accepts `"inline"`. `"forked"` runs a Node job in a fresh `child_process.fork()` child, and `"spawned"` in a detached CLI runner.
171
171
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
172
+ * @property {number} [priority] - Dispatch priority within the queue: higher runs first, default `0`, retries always run last.
172
173
  * @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
173
174
  * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
174
175
  * @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
@@ -207,6 +208,7 @@
207
208
  * @property {BackgroundJobStatus} status - Current job status.
208
209
  * @property {number | null} attempts - Failure attempts count.
209
210
  * @property {number | null} maxRetries - Max retry attempts.
211
+ * @property {number} priority - Dispatch priority; `-1` means retried and therefore queued last.
210
212
  * @property {number | null} scheduledAtMs - Next scheduled time in ms.
211
213
  * @property {number | null} createdAtMs - Creation time in ms.
212
214
  * @property {number | null} handedOffAtMs - Time handed to worker in ms.
@@ -157,6 +157,7 @@
157
157
  */
158
158
 
159
159
  import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
160
+ import BoundedTaskRunner from "../../utils/bounded-task-runner.js"
160
161
  import { getDatabaseAnnotations } from "../annotations.js"
161
162
  import { formatDateForDatabase } from "../datetime-storage.js"
162
163
  import isDate from "../../utils/is-date.js"
@@ -911,6 +912,65 @@ export default class VelociousDatabaseDriversBase {
911
912
  return await this._cachedSchemaMetadata(`table:${tableName}:${metadataName}`, callback)
912
913
  }
913
914
 
915
+ /**
916
+ * Seeds schema metadata that was loaded in one batch for many tables, so a later
917
+ * per-table read resolves from the cache instead of issuing its own query.
918
+ * @template T
919
+ * @param {string} tableName - Table name.
920
+ * @param {string} metadataName - Metadata name.
921
+ * @param {T} value - Already-resolved metadata.
922
+ * @returns {void}
923
+ */
924
+ seedTableSchemaMetadata(tableName, metadataName, value) {
925
+ if (!this._schemaCacheEnabled()) return
926
+
927
+ this._schemaCache.set(`table:${tableName}:${metadataName}`, Promise.resolve(value))
928
+ }
929
+
930
+ /**
931
+ * Loads column metadata for several tables, combining the reads into one query
932
+ * when the driver can answer for all of them at once and seeding the per-table
933
+ * cache so the models that follow initialize without querying.
934
+ * @param {Array<import("./base-table.js").default>} tables - Tables to load columns for.
935
+ * @param {{concurrency?: number}} [args] - Concurrency for drivers that can only read per table.
936
+ * @returns {Promise<void>}
937
+ */
938
+ async prefetchTableColumns(tables, {concurrency = 8} = {}) {
939
+ if (!this._schemaCacheEnabled()) return
940
+ if (tables.length === 0) return
941
+
942
+ const batchedColumns = await this.batchTableColumns(tables)
943
+
944
+ if (batchedColumns) {
945
+ for (const [tableName, columns] of batchedColumns) {
946
+ this.seedTableSchemaMetadata(tableName, "columns", columns)
947
+ }
948
+
949
+ return
950
+ }
951
+
952
+ await BoundedTaskRunner.run({
953
+ concurrency,
954
+ items: tables,
955
+ worker: async (table) => {
956
+ await table.getColumns()
957
+ }
958
+ })
959
+ }
960
+
961
+ /**
962
+ * Batched column metadata for many tables at once. Drivers whose engine cannot
963
+ * answer for several tables in one statement return null, leaving the caller to
964
+ * read them per table.
965
+ * @param {Array<import("./base-table.js").default>} tables - Tables to load columns for.
966
+ * @returns {Promise<Map<string, Array<import("./base-column.js").default>> | null>} - Columns keyed by table name, or null when unsupported.
967
+ */
968
+ async batchTableColumns(tables) {
969
+ void tables
970
+
971
+ return null
972
+ }
973
+
914
974
  /**
915
975
  * Runs schema cache return value.
916
976
  * @param {ReturnType<typeof JSON.parse>} value - Cached value.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import AlterTable from "./sql/alter-table.js"
4
4
  import Base from "../base.js"
5
+ import Column from "./column.js"
5
6
  import CreateDatabase from "./sql/create-database.js"
6
7
  import CreateIndex from "./sql/create-index.js"
7
8
  import CreateTable from "./sql/create-table.js"
@@ -516,6 +517,64 @@ export default class VelociousDatabaseDriversMssql extends Base{
516
517
  })
517
518
  }
518
519
 
520
+ /**
521
+ * Reads column metadata for many tables in one query. The per-table read of the
522
+ * same three-way `INFORMATION_SCHEMA` join is one of the slowest things a boot
523
+ * does against the legacy SQL Server, so it is worth combining regardless of the
524
+ * number of tables.
525
+ * @param {Array<import("../base-table.js").default>} tables - Tables to load columns for.
526
+ * @returns {Promise<Map<string, Array<import("../base-column.js").default>>>} - Columns keyed by table name.
527
+ */
528
+ async batchTableColumns(tables) {
529
+ /** @type {Map<string, Array<import("../base-column.js").default>>} */
530
+ const columnsByTable = new Map()
531
+
532
+ if (tables.length === 0) return columnsByTable
533
+
534
+ const schema = this.getArgs()?.schema || this.getArgs()?.sqlConfig?.options?.schema
535
+ const schemaClause = schema
536
+ ? ` AND columns.[TABLE_SCHEMA] = ${this.quote(schema)}`
537
+ : " AND columns.[TABLE_SCHEMA] = SCHEMA_NAME()"
538
+ const tablesByName = new Map(tables.map((table) => [table.getName(), table]))
539
+ const tableNamesSql = [...tablesByName.keys()].map((tableName) => this.quote(tableName)).join(", ")
540
+ const rows = await this.query(`
541
+ SELECT
542
+ columns.*,
543
+ COLUMNPROPERTY(object_id(columns.TABLE_SCHEMA + '.' + columns.TABLE_NAME), columns.COLUMN_NAME, 'IsIdentity') AS isIdentity,
544
+ CASE WHEN keyColumnUsage.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS isPrimaryKey
545
+ FROM [INFORMATION_SCHEMA].[COLUMNS] AS columns
546
+ LEFT JOIN [INFORMATION_SCHEMA].[TABLE_CONSTRAINTS] AS tableConstraints ON
547
+ tableConstraints.CONSTRAINT_TYPE = 'PRIMARY KEY' AND
548
+ tableConstraints.TABLE_CATALOG = columns.TABLE_CATALOG AND
549
+ tableConstraints.TABLE_SCHEMA = columns.TABLE_SCHEMA AND
550
+ tableConstraints.TABLE_NAME = columns.TABLE_NAME
551
+ LEFT JOIN [INFORMATION_SCHEMA].[KEY_COLUMN_USAGE] AS keyColumnUsage ON
552
+ keyColumnUsage.CONSTRAINT_CATALOG = tableConstraints.CONSTRAINT_CATALOG AND
553
+ keyColumnUsage.CONSTRAINT_SCHEMA = tableConstraints.CONSTRAINT_SCHEMA AND
554
+ keyColumnUsage.CONSTRAINT_NAME = tableConstraints.CONSTRAINT_NAME AND
555
+ keyColumnUsage.TABLE_CATALOG = columns.TABLE_CATALOG AND
556
+ keyColumnUsage.TABLE_SCHEMA = columns.TABLE_SCHEMA AND
557
+ keyColumnUsage.TABLE_NAME = columns.TABLE_NAME AND
558
+ keyColumnUsage.COLUMN_NAME = columns.COLUMN_NAME
559
+ WHERE columns.TABLE_NAME IN (${tableNamesSql})${schemaClause}
560
+ ORDER BY columns.TABLE_NAME, columns.ORDINAL_POSITION
561
+ `)
562
+
563
+ for (const row of rows) {
564
+ const tableName = /** @type {string} */ (row.TABLE_NAME)
565
+ const table = tablesByName.get(tableName)
566
+
567
+ if (!table) continue
568
+
569
+ const columns = columnsByTable.get(tableName) || []
570
+
571
+ columns.push(new Column(table, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row)))
572
+ columnsByTable.set(tableName, columns)
573
+ }
574
+
575
+ return columnsByTable
576
+ }
577
+
519
578
  /**
520
579
  * Keeps constraint toggles and cleanup inside each physical SQL Server
521
580
  * request so a pool cannot split lifecycle ownership across sessions.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import AlterTable from "./sql/alter-table.js"
4
4
  import Base from "../base.js"
5
+ import Column from "./column.js"
5
6
  import CreateDatabase from "./sql/create-database.js"
6
7
  import CreateIndex from "./sql/create-index.js"
7
8
  import CreateTable from "./sql/create-table.js"
@@ -765,6 +766,56 @@ export default class VelociousDatabaseDriversMysql extends Base{
765
766
  })
766
767
  }
767
768
 
769
+ /**
770
+ * Reads column metadata for many tables in one query. `SHOW FULL COLUMNS` only
771
+ * describes a single table, but `information_schema.COLUMNS` carries the same
772
+ * fields, so the result is aliased to the shape `Column` already reads.
773
+ *
774
+ * `NULLIF(COLUMN_DEFAULT, 'NULL')` compensates for MariaDB reporting a SQL NULL
775
+ * default as the literal string 'NULL', where `SHOW FULL COLUMNS` reports NULL.
776
+ * Verified column-for-column against `SHOW FULL COLUMNS` on a 44-table MariaDB
777
+ * schema; the only divergence was that sentinel.
778
+ * @param {Array<import("../base-table.js").default>} tables - Tables to load columns for.
779
+ * @returns {Promise<Map<string, Array<import("../base-column.js").default>>>} - Columns keyed by table name.
780
+ */
781
+ async batchTableColumns(tables) {
782
+ /** @type {Map<string, Array<import("../base-column.js").default>>} */
783
+ const columnsByTable = new Map()
784
+
785
+ if (tables.length === 0) return columnsByTable
786
+
787
+ const tablesByName = new Map(tables.map((table) => [table.getName(), table]))
788
+ const tableNamesSql = [...tablesByName.keys()].map((tableName) => this.quote(tableName)).join(", ")
789
+ const rows = await this.query(`
790
+ SELECT
791
+ TABLE_NAME AS tableName,
792
+ COLUMN_NAME AS Field,
793
+ COLUMN_TYPE AS Type,
794
+ IS_NULLABLE AS \`Null\`,
795
+ COLUMN_KEY AS \`Key\`,
796
+ NULLIF(COLUMN_DEFAULT, 'NULL') AS \`Default\`,
797
+ EXTRA AS Extra,
798
+ COLUMN_COMMENT AS Comment
799
+ FROM information_schema.COLUMNS
800
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${tableNamesSql})
801
+ ORDER BY TABLE_NAME, ORDINAL_POSITION
802
+ `)
803
+
804
+ for (const row of rows) {
805
+ const tableName = /** @type {string} */ (row.tableName)
806
+ const table = tablesByName.get(tableName)
807
+
808
+ if (!table) continue
809
+
810
+ const columns = columnsByTable.get(tableName) || []
811
+
812
+ columns.push(new Column(table, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row)))
813
+ columnsByTable.set(tableName, columns)
814
+ }
815
+
816
+ return columnsByTable
817
+ }
818
+
768
819
  /**
769
820
  * Runs structure sql.
770
821
  * @returns {Promise<string | null>} - Resolves with SQL string.