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
@@ -3,6 +3,7 @@
3
3
  import AlterTable from "./sql/alter-table.js"
4
4
  import wait from "awaitery/build/wait.js"
5
5
  import Base from "../base.js"
6
+ import Column from "./column.js"
6
7
  import { Client, types as pgTypes } from "pg"
7
8
  import CreateDatabase from "./sql/create-database.js"
8
9
  import CreateIndex from "./sql/create-index.js"
@@ -329,6 +330,10 @@ export default class VelociousDatabaseDriversPgsql extends Base{
329
330
  return insert.toSql()
330
331
  }
331
332
 
333
+ /**
334
+ * Runs get tables.
335
+ * @returns {Promise<Array<import("../base-table.js").default>>} - Resolves with the tables.
336
+ */
332
337
  async getTables() {
333
338
  return await this._cachedSchemaMetadata("tables", async () => {
334
339
  const result = await this.query("SELECT * FROM information_schema.tables WHERE table_catalog = CURRENT_DATABASE() AND table_schema = 'public'")
@@ -344,6 +349,62 @@ export default class VelociousDatabaseDriversPgsql extends Base{
344
349
  })
345
350
  }
346
351
 
352
+ /**
353
+ * Reads column metadata for many tables in one query instead of one catalog
354
+ * join per table.
355
+ * @param {Array<import("../base-table.js").default>} tables - Tables to load columns for.
356
+ * @returns {Promise<Map<string, Array<import("../base-column.js").default>>>} - Columns keyed by table name.
357
+ */
358
+ async batchTableColumns(tables) {
359
+ /** @type {Map<string, Array<import("../base-column.js").default>>} */
360
+ const columnsByTable = new Map()
361
+
362
+ if (tables.length === 0) return columnsByTable
363
+
364
+ const tablesByName = new Map(tables.map((table) => [table.getName(), table]))
365
+ const tableNamesSql = [...tablesByName.keys()].map((tableName) => this.quote(tableName)).join(", ")
366
+ const rows = await this.query(`
367
+ SELECT
368
+ columns.*,
369
+ CASE WHEN key_column_usage.column_name IS NOT NULL THEN 1 ELSE 0 END AS is_primary_key,
370
+ col_description((columns.table_schema || '.' || columns.table_name)::regclass, columns.ordinal_position) AS column_comment
371
+
372
+ FROM
373
+ information_schema.columns AS columns
374
+
375
+ LEFT JOIN information_schema.table_constraints AS table_constraints ON
376
+ table_constraints.table_name = columns.table_name AND
377
+ table_constraints.table_schema = columns.table_schema AND
378
+ table_constraints.constraint_type = 'PRIMARY KEY'
379
+
380
+ LEFT JOIN information_schema.key_column_usage AS key_column_usage ON
381
+ key_column_usage.constraint_name = table_constraints.constraint_name AND
382
+ key_column_usage.table_schema = table_constraints.table_schema AND
383
+ key_column_usage.table_name = columns.table_name AND
384
+ key_column_usage.column_name = columns.column_name
385
+
386
+ WHERE
387
+ columns.table_catalog = CURRENT_DATABASE() AND
388
+ columns.table_schema = 'public' AND
389
+ columns.table_name IN (${tableNamesSql})
390
+ ORDER BY columns.table_name, columns.ordinal_position
391
+ `)
392
+
393
+ for (const row of rows) {
394
+ const tableName = /** @type {string} */ (row.table_name)
395
+ const table = tablesByName.get(tableName)
396
+
397
+ if (!table) continue
398
+
399
+ const columns = columnsByTable.get(tableName) || []
400
+
401
+ columns.push(new Column(table, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row)))
402
+ columnsByTable.set(tableName, columns)
403
+ }
404
+
405
+ return columnsByTable
406
+ }
407
+
347
408
  /**
348
409
  * Truncates all eligible tables in one PostgreSQL request.
349
410
  * @param {Array<import("../base-table.js").default>} tables - Eligible tables.
@@ -10,7 +10,15 @@
10
10
  */
11
11
 
12
12
  import Logger from "../logger.js"
13
+ import BoundedTaskRunner from "../utils/bounded-task-runner.js"
13
14
  import restArgsError from "../utils/rest-args-error.js"
15
+ import SchemaMetadataPrefetcher from "./schema-metadata-prefetcher.js"
16
+
17
+ /**
18
+ * How many model classes initialize at once. Metadata comes from the prefetched
19
+ * schema cache, so this is CPU-bound attribute mapping rather than database work.
20
+ */
21
+ const MODEL_INITIALIZATION_CONCURRENCY = 8
14
22
 
15
23
  export default class VelociousDatabaseInitializerFromRequireContext {
16
24
  /**
@@ -34,6 +42,9 @@ export default class VelociousDatabaseInitializerFromRequireContext {
34
42
  async initialize({configuration, ...restArgs}) {
35
43
  restArgsError(restArgs)
36
44
 
45
+ /** @type {Array<typeof import("./record/index.js").default>} */
46
+ const modelClasses = []
47
+
37
48
  for (const fileName of this.requireContext.keys()) {
38
49
  const modelClassImport = this.requireContext(fileName)
39
50
 
@@ -50,14 +61,27 @@ export default class VelociousDatabaseInitializerFromRequireContext {
50
61
  continue
51
62
  }
52
63
 
53
- if (!modelClass.getEagerLoadRecordMetadata()) {
54
- modelClass.registerRecordClass({configuration})
55
- await this._bestEffortInitializeDeferredModel({configuration, modelClass})
56
- continue
57
- }
58
-
59
- await this._initializeModelRecord({configuration, modelClass})
64
+ modelClass.registerRecordClass({configuration})
65
+ modelClasses.push(modelClass)
60
66
  }
67
+
68
+ // One catalog read per database instead of one per model, shared by every
69
+ // model initialized below through the driver's schema cache.
70
+ await new SchemaMetadataPrefetcher({configuration, logger: this.logger}).prefetch({modelClasses})
71
+
72
+ await BoundedTaskRunner.run({
73
+ concurrency: MODEL_INITIALIZATION_CONCURRENCY,
74
+ items: modelClasses,
75
+ worker: async (modelClass) => {
76
+ if (!modelClass.getEagerLoadRecordMetadata()) {
77
+ await this._bestEffortInitializeDeferredModel({configuration, modelClass})
78
+
79
+ return
80
+ }
81
+
82
+ await this._initializeModelRecord({configuration, modelClass})
83
+ }
84
+ })
61
85
  }
62
86
 
63
87
  /**
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+
3
+ import Logger from "../logger.js"
4
+
5
+ /**
6
+ * Loads table and column metadata for a whole set of model classes up front.
7
+ *
8
+ * Model initialization otherwise reads each table's columns on its own, which
9
+ * turns a boot into one catalog query per model (and per HTTP worker process).
10
+ * Drivers that can answer for many tables in one statement are asked to do so;
11
+ * the resulting columns are seeded into the driver's per-table schema cache, so
12
+ * the initialization pass that follows resolves without querying at all.
13
+ */
14
+ export default class SchemaMetadataPrefetcher {
15
+ /**
16
+ * Builds a prefetcher for one configuration.
17
+ * @param {object} args - Prefetcher args.
18
+ * @param {import("../configuration.js").default} args.configuration - Configuration owning the database connections.
19
+ * @param {number} [args.concurrency] - Concurrency for drivers that can only read one table per query.
20
+ * @param {import("../logger.js").default} [args.logger] - Logger for debug output.
21
+ */
22
+ constructor({configuration, concurrency = 8, logger}) {
23
+ this.configuration = configuration
24
+ this.concurrency = concurrency
25
+ this.logger = logger || new Logger(this)
26
+ }
27
+
28
+ /**
29
+ * Prefetches column metadata for every model class that has a reachable table.
30
+ *
31
+ * Models whose table or connection is unavailable are skipped: their
32
+ * initialization stays deferred and will initialize lazily on first use, which
33
+ * is the same contract the per-model initialization applies.
34
+ * @param {{modelClasses: Array<typeof import("./record/index.js").default>}} args - Model classes to prefetch for.
35
+ * @returns {Promise<void>}
36
+ */
37
+ async prefetch({modelClasses}) {
38
+ /** @type {Map<import("./drivers/base.js").default, Map<string, import("./drivers/base-table.js").default>>} */
39
+ const tablesByDriver = new Map()
40
+
41
+ for (const modelClass of modelClasses) {
42
+ const table = await this.tableFor(modelClass)
43
+
44
+ if (!table) continue
45
+
46
+ const driver = table.getDriver()
47
+ let tables = tablesByDriver.get(driver)
48
+
49
+ if (!tables) {
50
+ tables = new Map()
51
+ tablesByDriver.set(driver, tables)
52
+ }
53
+
54
+ tables.set(table.getName(), table)
55
+
56
+ // Translated models initialize their translation class as part of the same
57
+ // startup pass, so its table has to be part of the batch as well - otherwise
58
+ // every translated model still costs its own catalog read.
59
+ const translationTable = await this.tableByName(driver, modelClass.getTranslationsTableName())
60
+
61
+ if (translationTable) tables.set(translationTable.getName(), translationTable)
62
+ }
63
+
64
+ const startedAt = Date.now()
65
+ let tableCount = 0
66
+
67
+ for (const [driver, tables] of tablesByDriver) {
68
+ await driver.prefetchTableColumns([...tables.values()], {concurrency: this.concurrency})
69
+ tableCount += tables.size
70
+ }
71
+
72
+ void this.logger.debug(() => ["schema metadata prefetched", {
73
+ databases: tablesByDriver.size,
74
+ durationMs: Date.now() - startedAt,
75
+ tables: tableCount
76
+ }])
77
+ }
78
+
79
+ /**
80
+ * Resolves a model's table without initializing its record metadata.
81
+ * @param {typeof import("./record/index.js").default} modelClass - Model class to resolve a table for.
82
+ * @returns {Promise<import("./drivers/base-table.js").default | null>} Table, or null when it is not reachable.
83
+ */
84
+ async tableFor(modelClass) {
85
+ try {
86
+ const driver = modelClass.connection({enforceTenantDatabaseScope: false})
87
+
88
+ return await this.tableByName(driver, modelClass.tableName())
89
+ } catch (error) {
90
+ // A model whose database is unreachable - or whose table is optional and
91
+ // absent - stays deferred rather than failing startup; it initializes
92
+ // lazily on first use.
93
+ void this.logger.debug(() => [`Leaving ${modelClass.name} out of schema prefetch`, error])
94
+
95
+ return null
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Looks a table up in the driver's table list without throwing when absent.
101
+ * @param {import("./drivers/base.js").default} driver - Driver owning the table.
102
+ * @param {string} tableName - Table name to resolve.
103
+ * @returns {Promise<import("./drivers/base-table.js").default | null>} Table, or null when it does not exist.
104
+ */
105
+ async tableByName(driver, tableName) {
106
+ return await driver.getTableByName(tableName, {throwError: false}) || null
107
+ }
108
+ }
@@ -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
+ }