velocious 1.0.591 → 1.0.593
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 +9 -1
- package/build/configuration-types.js +1 -1
- package/build/database/drivers/base.js +53 -34
- package/build/database/drivers/mssql/index.js +32 -0
- package/build/database/drivers/mysql/index.js +17 -0
- package/build/database/drivers/pgsql/index.js +11 -0
- package/build/database/drivers/sqlite/base.js +12 -0
- package/build/database/drivers/sqlite/connection-sql-js.js +3 -2
- package/build/database/drivers/sqlite/index.js +7 -1
- package/build/database/drivers/sqlite/index.native.js +9 -3
- package/build/database/drivers/sqlite/index.web.js +6 -3
- package/build/environment-handlers/base.js +18 -0
- package/build/environment-handlers/node.js +32 -0
- package/build/src/configuration-types.d.ts +2 -2
- package/build/src/configuration-types.js +2 -2
- package/build/src/database/drivers/base.d.ts +11 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +54 -37
- package/build/src/database/drivers/mssql/index.d.ts +7 -0
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +15 -1
- package/build/src/database/drivers/mysql/index.d.ts +7 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +15 -1
- package/build/src/database/drivers/pgsql/index.d.ts +6 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +10 -1
- package/build/src/database/drivers/sqlite/base.d.ts +7 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +11 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +4 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.js +4 -3
- package/build/src/database/drivers/sqlite/index.d.ts +2 -1
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.js +7 -2
- package/build/src/database/drivers/sqlite/index.native.d.ts +2 -1
- package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.native.js +9 -4
- package/build/src/database/drivers/sqlite/index.web.d.ts +2 -1
- package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.web.js +7 -4
- package/build/src/environment-handlers/base.d.ts +16 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +17 -1
- package/build/src/environment-handlers/node.d.ts +20 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +29 -1
- package/build/src/sync/conflict-strategy.d.ts.map +1 -1
- package/build/src/sync/conflict-strategy.js +7 -1
- package/build/src/sync/local-mutation-log.d.ts +10 -0
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +21 -1
- package/build/src/sync/sync-api-client-types.d.ts +15 -0
- package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-api-client-types.js +4 -1
- package/build/src/sync/sync-api-client.d.ts +91 -0
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +184 -1
- package/build/src/sync/sync-client-types.d.ts +57 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +16 -1
- package/build/src/sync/sync-client.d.ts +56 -7
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +180 -15
- package/build/src/sync/sync-envelope-replay-service.d.ts +31 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +98 -16
- package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
- package/build/src/sync/sync-model-change-feed-service.js +3 -4
- package/build/src/sync/sync-replay-persisted-data.d.ts +26 -0
- package/build/src/sync/sync-replay-persisted-data.d.ts.map +1 -0
- package/build/src/sync/sync-replay-persisted-data.js +43 -0
- package/build/src/testing/shared-transaction-broker.d.ts +14 -0
- package/build/src/testing/shared-transaction-broker.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-broker.js +44 -7
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts +5 -4
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-connection-coordinator.js +21 -10
- package/build/sync/conflict-strategy.js +3 -0
- package/build/sync/local-mutation-log.js +24 -0
- package/build/sync/sync-api-client-types.js +3 -0
- package/build/sync/sync-api-client.js +203 -0
- package/build/sync/sync-client-types.js +16 -0
- package/build/sync/sync-client.js +198 -14
- package/build/sync/sync-envelope-replay-service.js +109 -12
- package/build/sync/sync-model-change-feed-service.js +2 -4
- package/build/sync/sync-replay-persisted-data.js +50 -0
- package/build/testing/shared-transaction-broker.js +48 -7
- package/build/testing/shared-transaction-connection-coordinator.js +23 -9
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/scripts/browser-test-session.js +489 -0
- package/scripts/prewarm-chromedriver.js +3 -22
- package/scripts/test-browser.js +47 -15
- package/src/configuration-types.js +1 -1
- package/src/database/drivers/base.js +53 -34
- package/src/database/drivers/mssql/index.js +32 -0
- package/src/database/drivers/mysql/index.js +17 -0
- package/src/database/drivers/pgsql/index.js +11 -0
- package/src/database/drivers/sqlite/base.js +12 -0
- package/src/database/drivers/sqlite/connection-sql-js.js +3 -2
- package/src/database/drivers/sqlite/index.js +7 -1
- package/src/database/drivers/sqlite/index.native.js +9 -3
- package/src/database/drivers/sqlite/index.web.js +6 -3
- package/src/environment-handlers/base.js +18 -0
- package/src/environment-handlers/node.js +32 -0
- package/src/sync/conflict-strategy.js +3 -0
- package/src/sync/local-mutation-log.js +24 -0
- package/src/sync/sync-api-client-types.js +3 -0
- package/src/sync/sync-api-client.js +203 -0
- package/src/sync/sync-client-types.js +16 -0
- package/src/sync/sync-client.js +198 -14
- package/src/sync/sync-envelope-replay-service.js +109 -12
- package/src/sync/sync-model-change-feed-service.js +2 -4
- package/src/sync/sync-replay-persisted-data.js +50 -0
- package/src/testing/shared-transaction-broker.js +48 -7
- package/src/testing/shared-transaction-connection-coordinator.js +23 -9
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
* @property {boolean} [processListComment] - Whether to add process-list comments to the query.
|
|
66
66
|
* @property {boolean} [retry] - Whether retryable errors may retry the query; defaults to true.
|
|
67
67
|
* @property {boolean} [sessionTimeZone] - Whether to ensure the configured database session time zone before the query.
|
|
68
|
+
* @property {boolean} [sqliteScript] - Internal SQLite flag selecting native multi-statement script execution.
|
|
68
69
|
* @property {AbortSignal} [signal] - Aborts the in-flight query (destroying its connection) when it fires.
|
|
69
70
|
* @property {string} [sourceStack] - Stack captured at the caller boundary.
|
|
70
71
|
* @property {symbol} [operationOwner] - Opaque owner for an operation-leased connection.
|
|
@@ -141,7 +142,8 @@ import TableData from "../table-data/index.js"
|
|
|
141
142
|
import TableColumn from "../table-data/table-column.js"
|
|
142
143
|
import TableForeignKey from "../table-data/table-foreign-key.js"
|
|
143
144
|
import wait from "awaitery/build/wait.js"
|
|
144
|
-
import {optionalPositiveInteger} from "typanic"
|
|
145
|
+
import { optionalPositiveInteger } from "typanic"
|
|
146
|
+
import { coordinateSharedTransactionConnection } from "../../testing/shared-transaction-connection-coordinator.js"
|
|
145
147
|
|
|
146
148
|
/** Maximum characters inspected when building the debug SQL preview. */
|
|
147
149
|
const SQL_PREVIEW_SCAN_LIMIT = 4096
|
|
@@ -1594,13 +1596,16 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1594
1596
|
async affectedRows(sql, options = {}) {
|
|
1595
1597
|
await this._waitForOperationLease(options.operationOwner)
|
|
1596
1598
|
this._assertWritableQuery(sql)
|
|
1597
|
-
await this.beforeQuery(sql, options)
|
|
1598
1599
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1600
|
+
return await coordinateSharedTransactionConnection(this, async () => {
|
|
1601
|
+
await this.beforeQuery(sql, options)
|
|
1602
|
+
|
|
1603
|
+
try {
|
|
1604
|
+
return await this._affectedRowsActual(sql)
|
|
1605
|
+
} finally {
|
|
1606
|
+
await this.afterQuery(sql, options)
|
|
1607
|
+
}
|
|
1608
|
+
}, options.operationOwner)
|
|
1604
1609
|
}
|
|
1605
1610
|
|
|
1606
1611
|
/**
|
|
@@ -1663,13 +1668,15 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1663
1668
|
* @returns {Promise<QueryResultType>} - Resolves with the query.
|
|
1664
1669
|
*/
|
|
1665
1670
|
async _queryActualWithHooks(sql, options) {
|
|
1666
|
-
await this
|
|
1671
|
+
return await coordinateSharedTransactionConnection(this, async () => {
|
|
1672
|
+
await this.beforeQuery(sql, options)
|
|
1667
1673
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1674
|
+
try {
|
|
1675
|
+
return await this._queryActual(sql, options)
|
|
1676
|
+
} finally {
|
|
1677
|
+
await this.afterQuery(sql, options)
|
|
1678
|
+
}
|
|
1679
|
+
}, options.operationOwner)
|
|
1673
1680
|
}
|
|
1674
1681
|
|
|
1675
1682
|
/**
|
|
@@ -2214,42 +2221,54 @@ export default class VelociousDatabaseDriversBase {
|
|
|
2214
2221
|
await this.query(`ROLLBACK TO SAVEPOINT ${savePointName}`, options)
|
|
2215
2222
|
}
|
|
2216
2223
|
|
|
2224
|
+
/**
|
|
2225
|
+
* Truncates the given table snapshot. Drivers can override this to issue one batch.
|
|
2226
|
+
* @protected
|
|
2227
|
+
* @param {Array<import("./base-table.js").default>} tables - Eligible tables for this cleanup attempt.
|
|
2228
|
+
* @returns {Promise<void>} - Resolves when every table has been cleaned.
|
|
2229
|
+
*/
|
|
2230
|
+
async truncateTables(tables) {
|
|
2231
|
+
const truncateErrors = []
|
|
2232
|
+
|
|
2233
|
+
for (const table of tables) {
|
|
2234
|
+
try {
|
|
2235
|
+
await table.truncate({cascade: true})
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
truncateErrors.push(error)
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
if (truncateErrors.length > 0) throw truncateErrors[0]
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2217
2244
|
/**
|
|
2218
2245
|
* Runs truncate all tables.
|
|
2219
2246
|
* @returns {Promise<void>} - Resolves when complete.
|
|
2220
2247
|
*/
|
|
2221
2248
|
async truncateAllTables() {
|
|
2222
2249
|
this._assertNotReadOnly()
|
|
2223
|
-
await this.
|
|
2224
|
-
let tries = 0
|
|
2250
|
+
let tables = (await this.getTables()).filter((table) => table.getName() != "schema_migrations")
|
|
2225
2251
|
|
|
2226
|
-
|
|
2227
|
-
tries++
|
|
2252
|
+
if (tables.length == 0) return
|
|
2228
2253
|
|
|
2229
|
-
|
|
2230
|
-
|
|
2254
|
+
await this.withDisabledForeignKeys(async () => {
|
|
2255
|
+
for (let tries = 1; tries <= 6; tries++) {
|
|
2256
|
+
try {
|
|
2257
|
+
await this.truncateTables(tables)
|
|
2258
|
+
return
|
|
2259
|
+
} catch (error) {
|
|
2260
|
+
console.error(error)
|
|
2231
2261
|
|
|
2232
|
-
|
|
2233
|
-
if (table.getName() != "schema_migrations") {
|
|
2234
|
-
try {
|
|
2235
|
-
await table.truncate({cascade: true})
|
|
2236
|
-
} catch (error) {
|
|
2237
|
-
console.error(error)
|
|
2238
|
-
truncateErrors.push(error)
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2262
|
+
if (tries == 6) throw error
|
|
2242
2263
|
|
|
2243
|
-
if (truncateErrors.length == 0) {
|
|
2244
|
-
break
|
|
2245
|
-
} else if (tries <= 5) {
|
|
2246
2264
|
// A truncate failed — the schema cache may still list a table that was
|
|
2247
2265
|
// dropped out from under us (e.g. a db:rollback test that left the
|
|
2248
2266
|
// shared DB rolled back). Clear it so the next pass re-reads the live
|
|
2249
2267
|
// table list and no longer tries to truncate a table that is gone.
|
|
2250
2268
|
this.clearSchemaCache()
|
|
2251
|
-
|
|
2252
|
-
|
|
2269
|
+
tables = (await this.getTables()).filter((table) => table.getName() != "schema_migrations")
|
|
2270
|
+
|
|
2271
|
+
if (tables.length == 0) return
|
|
2253
2272
|
}
|
|
2254
2273
|
}
|
|
2255
2274
|
})
|
|
@@ -506,6 +506,38 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
506
506
|
})
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Truncates all eligible tables in one SQL Server request, retaining the
|
|
511
|
+
* recognized foreign-key fallback used by the per-table implementation.
|
|
512
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
513
|
+
* @returns {Promise<void>} - Resolves when the batch completes.
|
|
514
|
+
*/
|
|
515
|
+
async truncateTables(tables) {
|
|
516
|
+
const statements = []
|
|
517
|
+
|
|
518
|
+
for (const table of tables) {
|
|
519
|
+
const quotedTable = this.quoteTable(table.getName())
|
|
520
|
+
|
|
521
|
+
statements.push(
|
|
522
|
+
"BEGIN TRY",
|
|
523
|
+
` TRUNCATE TABLE ${quotedTable};`,
|
|
524
|
+
"END TRY",
|
|
525
|
+
"BEGIN CATCH",
|
|
526
|
+
" IF ERROR_NUMBER() = 4712",
|
|
527
|
+
" BEGIN",
|
|
528
|
+
` DELETE FROM ${quotedTable};`,
|
|
529
|
+
" END",
|
|
530
|
+
" ELSE",
|
|
531
|
+
" BEGIN",
|
|
532
|
+
" THROW;",
|
|
533
|
+
" END",
|
|
534
|
+
"END CATCH;"
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
await this.query(statements.join("\n"))
|
|
539
|
+
}
|
|
540
|
+
|
|
509
541
|
async lastInsertID(options = {}) {
|
|
510
542
|
const result = await this.query("SELECT SCOPE_IDENTITY() AS last_insert_id", options)
|
|
511
543
|
const lastInsertID = digg(result, 0, "last_insert_id")
|
|
@@ -486,6 +486,23 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
486
486
|
return true
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Uses one multi-statement request only when the existing connection option
|
|
491
|
+
* explicitly allows it; otherwise retains the base sequential behavior.
|
|
492
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
493
|
+
* @returns {Promise<void>} - Resolves when every table has been truncated.
|
|
494
|
+
*/
|
|
495
|
+
async truncateTables(tables) {
|
|
496
|
+
if (!this.getArgs().multipleStatements) {
|
|
497
|
+
await super.truncateTables(tables)
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const statements = tables.map((table) => `TRUNCATE TABLE ${this.quoteTable(table.getName())}`)
|
|
502
|
+
|
|
503
|
+
await this.query(statements.join(";\n"))
|
|
504
|
+
}
|
|
505
|
+
|
|
489
506
|
/**
|
|
490
507
|
* Runs query to sql.
|
|
491
508
|
* @param {import("../../query/index.js").default} query - Query instance.
|
|
@@ -326,6 +326,17 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
326
326
|
})
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Truncates all eligible tables in one PostgreSQL request.
|
|
331
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
332
|
+
* @returns {Promise<void>} - Resolves when the batch completes.
|
|
333
|
+
*/
|
|
334
|
+
async truncateTables(tables) {
|
|
335
|
+
const quotedTables = tables.map((table) => this.quoteTable(table.getName()))
|
|
336
|
+
|
|
337
|
+
await this.query(`TRUNCATE TABLE ${quotedTables.join(", ")} CASCADE`)
|
|
338
|
+
}
|
|
339
|
+
|
|
329
340
|
async lastInsertID(options = {}) {
|
|
330
341
|
const result = await this.query("SELECT LASTVAL() AS last_insert_id", options)
|
|
331
342
|
|
|
@@ -161,6 +161,18 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
|
|
|
161
161
|
})
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Deletes every eligible table through the platform driver's native SQLite
|
|
166
|
+
* script path so the whole cleanup is submitted as one request.
|
|
167
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
168
|
+
* @returns {Promise<void>} - Resolves when the script completes.
|
|
169
|
+
*/
|
|
170
|
+
async truncateTables(tables) {
|
|
171
|
+
const statements = tables.map((table) => `DELETE FROM ${this.quoteTable(table.getName())}`)
|
|
172
|
+
|
|
173
|
+
await this.query(statements.join(";\n"), {sqliteScript: true})
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
/**
|
|
165
177
|
* Runs insert multiple.
|
|
166
178
|
* @param {string} tableName - Table name.
|
|
@@ -89,14 +89,15 @@ export default class VelociousDatabaseDriversSqliteConnectionSqlJs {
|
|
|
89
89
|
/**
|
|
90
90
|
* Runs query.
|
|
91
91
|
* @param {string} sql - SQL string.
|
|
92
|
+
* @param {{mutation?: boolean}} [options] - Internal query classification options.
|
|
92
93
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>[]>} - Resolves with the query.
|
|
93
94
|
*/
|
|
94
|
-
async query(sql) {
|
|
95
|
+
async query(sql, {mutation = false} = {}) {
|
|
95
96
|
const result = await queryWeb(this.connection, sql)
|
|
96
97
|
const downcasedSQL = sql.toLowerCase().trim()
|
|
97
98
|
|
|
98
99
|
// Auto-save database in local storage in case we can find manipulating instructions in the SQL
|
|
99
|
-
if (downcasedSQL.startsWith("delete ") || downcasedSQL.startsWith("insert into ") || downcasedSQL.startsWith("update ")) {
|
|
100
|
+
if (mutation || downcasedSQL.startsWith("delete ") || downcasedSQL.startsWith("insert into ") || downcasedSQL.startsWith("update ")) {
|
|
100
101
|
this.saveDatabaseDebounce()
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -84,11 +84,17 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
|
84
84
|
/**
|
|
85
85
|
* Runs query actual.
|
|
86
86
|
* @param {string} sql - SQL string.
|
|
87
|
+
* @param {import("../base.js").QueryOptions} [options] - Query options.
|
|
87
88
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>[]>} - Resolves with the query actual.
|
|
88
89
|
*/
|
|
89
|
-
async _queryActual(sql) {
|
|
90
|
+
async _queryActual(sql, options = {}) {
|
|
90
91
|
if (!this.connection) throw new Error("No connection")
|
|
91
92
|
|
|
93
|
+
if (options.sqliteScript) {
|
|
94
|
+
await this.connection.exec(sql)
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
return await query(this.connection, sql)
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -82,13 +82,19 @@ export default class VelociousDatabaseDriversSqliteNative extends Base {
|
|
|
82
82
|
/**
|
|
83
83
|
* Runs query actual.
|
|
84
84
|
* @param {string} sql - SQL string.
|
|
85
|
+
* @param {import("../base.js").QueryOptions} [options] - Query options.
|
|
85
86
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>[]>} - Query result rows.
|
|
86
87
|
*/
|
|
87
|
-
async _queryActual(sql) {
|
|
88
|
-
return await this._queryMutex.sync(() => {
|
|
88
|
+
async _queryActual(sql, options = {}) {
|
|
89
|
+
return await this._queryMutex.sync(async () => {
|
|
89
90
|
if (!this.connection) throw new Error("Not connected yet")
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
if (options.sqliteScript) {
|
|
93
|
+
await this.connection.execAsync(sql)
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return await query(this.connection, sql)
|
|
92
98
|
})
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -178,13 +178,16 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
|
|
|
178
178
|
/**
|
|
179
179
|
* Runs query actual.
|
|
180
180
|
* @param {string} sql - SQL string.
|
|
181
|
+
* @param {import("../base.js").QueryOptions} [options] - Query options.
|
|
181
182
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>[]>} - Resolves with the query actual.
|
|
182
183
|
*/
|
|
183
|
-
async _queryActual(sql) {
|
|
184
|
-
const
|
|
184
|
+
async _queryActual(sql, options = {}) {
|
|
185
|
+
const connection = this.getConnection()
|
|
186
|
+
const result = connection instanceof ConnectionSqlJs
|
|
187
|
+
? await connection.query(sql, {mutation: options.sqliteScript === true})
|
|
188
|
+
: await connection.query(sql)
|
|
185
189
|
|
|
186
190
|
if (!Array.isArray(result)) {
|
|
187
|
-
const connection = this.getConnection()
|
|
188
191
|
const connectionName = connection?.constructor?.name || "UnknownConnection"
|
|
189
192
|
|
|
190
193
|
throw new Error(`Sqlite web connection ${connectionName} returned a non-array result: ${typeof result}`)
|
|
@@ -27,6 +27,24 @@ export default class VelociousEnvironmentHandlerBase {
|
|
|
27
27
|
*/
|
|
28
28
|
async createTestSharedTransactionConnection(_args) { return undefined }
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Gets the active shared-transaction coordinator owner for a connection.
|
|
32
|
+
* Browser runtimes never install the Node test broker.
|
|
33
|
+
* @param {object} _connection - Parent physical connection.
|
|
34
|
+
* @returns {symbol | undefined} - Active coordinator owner.
|
|
35
|
+
*/
|
|
36
|
+
getSharedTransactionCoordinatorOwner(_connection) { return undefined }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs work as the current shared-transaction coordinator owner.
|
|
40
|
+
* @template T
|
|
41
|
+
* @param {object} _connection - Parent physical connection.
|
|
42
|
+
* @param {symbol} _owner - Coordinator owner.
|
|
43
|
+
* @param {() => T} callback - Owned work.
|
|
44
|
+
* @returns {T} - Callback result.
|
|
45
|
+
*/
|
|
46
|
+
runWithSharedTransactionCoordinatorOwner(_connection, _owner, callback) { return callback() }
|
|
47
|
+
|
|
30
48
|
/**
|
|
31
49
|
* Mutable ambient tenant used by runtimes without async-context storage.
|
|
32
50
|
* @type {ReturnType<typeof JSON.parse> | undefined}
|
|
@@ -80,6 +80,38 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
80
80
|
* @type {import("node:async_hooks").AsyncLocalStorage<TimezoneStore> | undefined} */
|
|
81
81
|
_timezoneAsyncLocalStorage = NodeAsyncLocalStorage ? new NodeAsyncLocalStorage() : undefined
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Shared-transaction coordinator ownership by physical connection.
|
|
85
|
+
* @type {import("node:async_hooks").AsyncLocalStorage<Map<object, symbol>> | undefined}
|
|
86
|
+
*/
|
|
87
|
+
_sharedTransactionCoordinatorAsyncLocalStorage = NodeAsyncLocalStorage ? new NodeAsyncLocalStorage() : undefined
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Gets the active shared-transaction coordinator owner for a connection.
|
|
91
|
+
* @param {object} connection - Parent physical connection.
|
|
92
|
+
* @returns {symbol | undefined} - Active coordinator owner.
|
|
93
|
+
*/
|
|
94
|
+
getSharedTransactionCoordinatorOwner(connection) {
|
|
95
|
+
return this._sharedTransactionCoordinatorAsyncLocalStorage?.getStore()?.get(connection)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Runs work as the current shared-transaction coordinator owner.
|
|
100
|
+
* @template T
|
|
101
|
+
* @param {object} connection - Parent physical connection.
|
|
102
|
+
* @param {symbol} owner - Coordinator owner.
|
|
103
|
+
* @param {() => T} callback - Owned work.
|
|
104
|
+
* @returns {T} - Callback result.
|
|
105
|
+
*/
|
|
106
|
+
runWithSharedTransactionCoordinatorOwner(connection, owner, callback) {
|
|
107
|
+
if (!this._sharedTransactionCoordinatorAsyncLocalStorage) return callback()
|
|
108
|
+
|
|
109
|
+
const owners = new Map(this._sharedTransactionCoordinatorAsyncLocalStorage.getStore())
|
|
110
|
+
|
|
111
|
+
owners.set(connection, owner)
|
|
112
|
+
return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/**
|
|
84
116
|
* Find commands result.
|
|
85
117
|
* @type {import("./base.js").CommandFileObjectType[] | undefined} */
|
|
@@ -63,6 +63,9 @@ export async function applySyncReplayResultToLocalMutationLog({mutationLog, reco
|
|
|
63
63
|
* @returns {import("./local-mutation-log.js").LocalMutationStatus} - Local mutation-log status.
|
|
64
64
|
*/
|
|
65
65
|
export function replayResultLocalStatus(result) {
|
|
66
|
+
if (result.syncState === "conflict") return "conflict"
|
|
67
|
+
if (result.syncState === "failed" || result.syncState === "rejected") return "rejected"
|
|
68
|
+
if (result.syncState === "successful" || result.syncState === "duplicate") return "synced"
|
|
66
69
|
if (result.status === "conflict") return "conflict"
|
|
67
70
|
if (result.status === "error" || result.status === "rejected") return "rejected"
|
|
68
71
|
if (result.status === "success" && replayResultHasFailedApplication(result)) return "rejected"
|
|
@@ -148,6 +148,30 @@ export default class LocalMutationLog {
|
|
|
148
148
|
})
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Replaces the mutation payload of a still-pending record. Used when an
|
|
153
|
+
* acknowledged predecessor supplies the authoritative base for its successor.
|
|
154
|
+
* @param {{id: string, mutation: import("./device-identity.js").SyncMutation}} args - Record and replacement mutation.
|
|
155
|
+
* @returns {Promise<LocalMutationLogRecord>} Updated record.
|
|
156
|
+
*/
|
|
157
|
+
async updateMutation({id, mutation}) {
|
|
158
|
+
return await withStorageKeyLock(this.storageKey, async () => {
|
|
159
|
+
const rawRecord = await this.storage.record(this.storageKey, id)
|
|
160
|
+
|
|
161
|
+
if (!rawRecord) throw new Error(`No local mutation log record '${id}'`)
|
|
162
|
+
|
|
163
|
+
const record = normalizeRecord(rawRecord)
|
|
164
|
+
|
|
165
|
+
if (!PENDING_STATUSES.has(record.status)) throw new Error(`Cannot update mutation for ${record.status} local mutation '${id}'`)
|
|
166
|
+
|
|
167
|
+
record.mutation = normalizeMutation(mutation)
|
|
168
|
+
record.updatedAt = this.currentTimestamp()
|
|
169
|
+
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
170
|
+
|
|
171
|
+
return cloneRecord(record)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
/**
|
|
152
176
|
* Prunes terminal records that are no longer needed for replay dependencies.
|
|
153
177
|
* @param {object} [args] - Compaction options.
|
|
@@ -73,6 +73,9 @@
|
|
|
73
73
|
/**
|
|
74
74
|
* @typedef {object} SyncReplayItem
|
|
75
75
|
* @property {string | number} id - Sync id.
|
|
76
|
+
* @property {Record<string, ReturnType<typeof JSON.parse>>} [conflict] - Structured durable conflict payload.
|
|
77
|
+
* @property {string} [reason] - Rejection reason.
|
|
78
|
+
* @property {string | number | null} [serverVersion] - Authoritative version after successful replay.
|
|
76
79
|
* @property {string} syncState - Replay state.
|
|
77
80
|
*/
|
|
78
81
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {optionalBoolean, optionalInteger} from "typanic"
|
|
4
4
|
|
|
5
5
|
import recordChanges from "../database/record-changes.js"
|
|
6
|
+
import {applySyncReplayResultToLocalMutationLog} from "./conflict-strategy.js"
|
|
6
7
|
|
|
7
8
|
/** @typedef {import("./sync-api-client-types.js").SyncChangeApplyResult} SyncChangeApplyResult */
|
|
8
9
|
/** @typedef {import("./sync-api-client-types.js").SyncChangeEnvelope} SyncChangeEnvelope */
|
|
@@ -21,6 +22,208 @@ const syncTaskPromises = new Map()
|
|
|
21
22
|
* persistence/auth hooks.
|
|
22
23
|
*/
|
|
23
24
|
export default class SyncApiClient {
|
|
25
|
+
/**
|
|
26
|
+
* Appends one conflict-tracked intent to the existing durable mutation log.
|
|
27
|
+
* @param {object} args - Queue arguments.
|
|
28
|
+
* @param {string | number | null} args.baseVersion - Authoritative version observed before the local mutation.
|
|
29
|
+
* @param {import("./sync-client-types.js").SyncClientConflictTrackingConfig} args.conflictTracking - Durable tracking configuration.
|
|
30
|
+
* @param {Record<string, unknown>} args.data - Backend-safe mutation attributes.
|
|
31
|
+
* @param {"create" | "update" | "destroy"} args.operation - Local operation.
|
|
32
|
+
* @param {ReturnType<typeof JSON.parse>} args.resource - Local resource.
|
|
33
|
+
* @param {string} args.resourceType - Resource type.
|
|
34
|
+
* @param {string} args.syncType - Wire operation.
|
|
35
|
+
* @returns {Promise<import("./local-mutation-log.js").LocalMutationLogRecord>} Appended intent.
|
|
36
|
+
*/
|
|
37
|
+
static async queueConflictTrackedSync({baseVersion, conflictTracking, data, operation, resource, resourceType, syncType}) {
|
|
38
|
+
const resourceId = String(resource.id())
|
|
39
|
+
const records = await conflictTracking.mutationLog.records()
|
|
40
|
+
const predecessor = records
|
|
41
|
+
.filter((record) => record.mutation.model === resourceType && record.mutation.payload?.resourceId === resourceId)
|
|
42
|
+
.at(-1)
|
|
43
|
+
const clientMutationId = conflictTracking.clientMutationId()
|
|
44
|
+
const now = conflictTracking.now ? conflictTracking.now() : new Date()
|
|
45
|
+
const predecessorTime = predecessor ? new Date(predecessor.mutation.occurredAt).getTime() : Number.NEGATIVE_INFINITY
|
|
46
|
+
const occurredAt = new Date(Math.max(now.getTime(), predecessorTime + 1)).toISOString()
|
|
47
|
+
|
|
48
|
+
return await conflictTracking.mutationLog.append({
|
|
49
|
+
dependencies: predecessor ? [{clientMutationId: predecessor.mutation.clientMutationId, model: resourceType}] : [],
|
|
50
|
+
mutation: {
|
|
51
|
+
actorDeviceId: conflictTracking.actorDeviceId,
|
|
52
|
+
actorUserId: conflictTracking.actorUserId,
|
|
53
|
+
attributes: /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (data),
|
|
54
|
+
baseVersion,
|
|
55
|
+
clientMutationId,
|
|
56
|
+
model: resourceType,
|
|
57
|
+
occurredAt,
|
|
58
|
+
offlineGrantId: conflictTracking.offlineGrantId,
|
|
59
|
+
operation,
|
|
60
|
+
payload: {resourceId, syncType},
|
|
61
|
+
policyHash: conflictTracking.policyHash
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drains the existing mutation log in predecessor order. Independent records
|
|
68
|
+
* continue after durable conflicts/rejections; successors stay blocked.
|
|
69
|
+
* @param {object} args - Replay arguments.
|
|
70
|
+
* @param {string} args.authenticationToken - Authentication token.
|
|
71
|
+
* @param {number} [args.batchSize] - Batch size.
|
|
72
|
+
* @param {import("./sync-client-types.js").SyncClientConflictTrackingConfig} args.conflictTracking - Tracking configuration.
|
|
73
|
+
* @param {(payload: {authenticationToken: string, syncs: Array<Record<string, ReturnType<typeof JSON.parse>>>}) => Promise<SyncReplayResponse>} args.postReplay - Transport boundary.
|
|
74
|
+
* @param {(identity: string) => number} args.remoteGeneration - Current remote generation.
|
|
75
|
+
* @param {string} args.resourceType - Resource whose log records should drain.
|
|
76
|
+
* @returns {Promise<void>} Resolves when no ready intent remains.
|
|
77
|
+
*/
|
|
78
|
+
static async replayConflictTrackedSyncs({authenticationToken, batchSize, conflictTracking, postReplay, remoteGeneration, resourceType}) {
|
|
79
|
+
const maxBatchSize = this.normalizedBatchSize(batchSize)
|
|
80
|
+
|
|
81
|
+
while (true) {
|
|
82
|
+
const records = await conflictTracking.mutationLog.records()
|
|
83
|
+
const statuses = new Map(records.map((record) => [record.mutation.clientMutationId, record.status]))
|
|
84
|
+
const pending = records.filter((record) => record.status === "pending" && record.mutation.model === resourceType)
|
|
85
|
+
const ready = pending.filter((record) => record.dependencies.every((dependency) => statuses.get(dependency.clientMutationId) === "synced"))
|
|
86
|
+
|
|
87
|
+
if (ready.length === 0) return
|
|
88
|
+
|
|
89
|
+
const groups = this.conflictReplayGroups({pending, ready}).slice(0, maxBatchSize)
|
|
90
|
+
const generations = new Map(groups.map((group) => [group[0].mutation.clientMutationId, remoteGeneration(this.conflictRecordIdentity(group[0]))]))
|
|
91
|
+
const response = await postReplay({
|
|
92
|
+
authenticationToken,
|
|
93
|
+
syncs: groups.map((group) => this.conflictReplayPayload(group))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
this.ensureSuccessfulResponse(response)
|
|
97
|
+
|
|
98
|
+
const responsesById = new Map((response.syncs || []).map((result) => [String(result.id), result]))
|
|
99
|
+
|
|
100
|
+
for (const group of groups) {
|
|
101
|
+
const result = responsesById.get(group[0].mutation.clientMutationId)
|
|
102
|
+
|
|
103
|
+
if (!result) throw new Error(`Sync response missing result for mutation ${group[0].mutation.clientMutationId}`)
|
|
104
|
+
if (!["successful", "duplicate", "conflict", "failed", "rejected"].includes(result.syncState)) {
|
|
105
|
+
throw new Error(`Invalid sync state returned for mutation ${group[0].mutation.clientMutationId}: ${result.syncState}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const record of group) {
|
|
109
|
+
await applySyncReplayResultToLocalMutationLog({mutationLog: conflictTracking.mutationLog, record, result: /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (result)})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (["successful", "duplicate"].includes(result.syncState) && result.serverVersion !== undefined) {
|
|
113
|
+
const identity = this.conflictRecordIdentity(group[0])
|
|
114
|
+
|
|
115
|
+
if (remoteGeneration(identity) === generations.get(group[0].mutation.clientMutationId)) {
|
|
116
|
+
await this.rebaseConflictSuccessor({conflictTracking, predecessor: group[group.length - 1], serverVersion: result.serverVersion})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds safe transport groups from root-ready records and their successors.
|
|
125
|
+
* @param {{pending: Array<import("./local-mutation-log.js").LocalMutationLogRecord>, ready: Array<import("./local-mutation-log.js").LocalMutationLogRecord>}} args - Pending and root-ready records.
|
|
126
|
+
* @returns {Array<Array<import("./local-mutation-log.js").LocalMutationLogRecord>>} Safe transport groups.
|
|
127
|
+
*/
|
|
128
|
+
static conflictReplayGroups({pending, ready}) {
|
|
129
|
+
const groups = []
|
|
130
|
+
const selectedIdentities = new Set()
|
|
131
|
+
|
|
132
|
+
for (const record of ready) {
|
|
133
|
+
const identity = this.conflictRecordIdentity(record)
|
|
134
|
+
|
|
135
|
+
if (selectedIdentities.has(identity)) continue
|
|
136
|
+
|
|
137
|
+
const group = [record]
|
|
138
|
+
let tail = record
|
|
139
|
+
|
|
140
|
+
while (true) {
|
|
141
|
+
const successor = pending.find((candidate) => candidate.dependencies.some((dependency) => dependency.clientMutationId === tail.mutation.clientMutationId))
|
|
142
|
+
|
|
143
|
+
if (!successor || !this.canCoalesceConflictRecords(tail, successor)) break
|
|
144
|
+
group.push(successor)
|
|
145
|
+
tail = successor
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
groups.push(group)
|
|
149
|
+
selectedIdentities.add(identity)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return groups
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checks whether two durable intents can share one transport mutation.
|
|
157
|
+
* @param {import("./local-mutation-log.js").LocalMutationLogRecord} left - Earlier intent.
|
|
158
|
+
* @param {import("./local-mutation-log.js").LocalMutationLogRecord} right - Later intent.
|
|
159
|
+
* @returns {boolean} Whether scalar updates can share one transport mutation.
|
|
160
|
+
*/
|
|
161
|
+
static canCoalesceConflictRecords(left, right) {
|
|
162
|
+
if (left.mutation.operation !== "update" || right.mutation.operation !== "update") return false
|
|
163
|
+
if (this.conflictRecordIdentity(left) !== this.conflictRecordIdentity(right)) return false
|
|
164
|
+
if (left.mutation.baseVersion !== right.mutation.baseVersion) return false
|
|
165
|
+
if (!this.scalarSyncAttributes(left.mutation.attributes) || !this.scalarSyncAttributes(right.mutation.attributes)) return false
|
|
166
|
+
|
|
167
|
+
return !Object.keys(left.mutation.attributes || {}).some((key) => Object.hasOwn(right.mutation.attributes || {}, key))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks whether attributes contain scalar JSON values only.
|
|
172
|
+
* @param {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue> | undefined} attributes - Attributes.
|
|
173
|
+
* @returns {boolean} Whether every value is scalar.
|
|
174
|
+
*/
|
|
175
|
+
static scalarSyncAttributes(attributes) {
|
|
176
|
+
return Boolean(attributes) && Object.values(attributes || {}).every((value) => value === null || ["string", "number", "boolean"].includes(typeof value))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Builds one replay envelope for a safe transport group.
|
|
181
|
+
* @param {Array<import("./local-mutation-log.js").LocalMutationLogRecord>} group - Transport group.
|
|
182
|
+
* @returns {Record<string, ReturnType<typeof JSON.parse>>} Replay envelope.
|
|
183
|
+
*/
|
|
184
|
+
static conflictReplayPayload(group) {
|
|
185
|
+
const first = group[0]
|
|
186
|
+
const payload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ ({
|
|
187
|
+
baseVersion: first.mutation.baseVersion,
|
|
188
|
+
clientUpdatedAt: first.mutation.occurredAt,
|
|
189
|
+
data: Object.assign({}, ...group.map((record) => record.mutation.attributes || {})),
|
|
190
|
+
id: first.mutation.clientMutationId,
|
|
191
|
+
resourceId: first.mutation.payload?.resourceId,
|
|
192
|
+
resourceType: first.mutation.model,
|
|
193
|
+
syncType: first.mutation.payload?.syncType
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return payload
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Builds a stable resource identity for ordering and remote generations.
|
|
201
|
+
* @param {import("./local-mutation-log.js").LocalMutationLogRecord} record - Record.
|
|
202
|
+
* @returns {string} Resource identity.
|
|
203
|
+
*/
|
|
204
|
+
static conflictRecordIdentity(record) {
|
|
205
|
+
return `${record.mutation.model}:${String(record.mutation.payload?.resourceId)}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Rebases the direct pending successor from an authoritative acknowledgement.
|
|
210
|
+
* @param {object} args - Rebase args.
|
|
211
|
+
* @param {import("./sync-client-types.js").SyncClientConflictTrackingConfig} args.conflictTracking - Tracking config.
|
|
212
|
+
* @param {import("./local-mutation-log.js").LocalMutationLogRecord} args.predecessor - Acknowledged predecessor.
|
|
213
|
+
* @param {string | number | null} args.serverVersion - Authoritative server version.
|
|
214
|
+
* @returns {Promise<void>}
|
|
215
|
+
*/
|
|
216
|
+
static async rebaseConflictSuccessor({conflictTracking, predecessor, serverVersion}) {
|
|
217
|
+
const successor = (await conflictTracking.mutationLog.pendingRecords())
|
|
218
|
+
.find((record) => record.dependencies.some((dependency) => dependency.clientMutationId === predecessor.mutation.clientMutationId))
|
|
219
|
+
|
|
220
|
+
if (!successor) return
|
|
221
|
+
|
|
222
|
+
await conflictTracking.mutationLog.updateMutation({
|
|
223
|
+
id: successor.id,
|
|
224
|
+
mutation: {...successor.mutation, baseVersion: serverVersion}
|
|
225
|
+
})
|
|
226
|
+
}
|
|
24
227
|
/**
|
|
25
228
|
* Serializes sync work with the same key so callers do not have to keep app-local locks.
|
|
26
229
|
* @param {string} key - Lock key.
|