@g9tm/syncdata 1.2.1 → 1.3.0
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/PARITY.md +26 -2
- package/README.md +1 -1
- package/dist/abstractions/errors.d.ts +4 -0
- package/dist/abstractions/errors.js +4 -0
- package/dist/abstractions/errors.js.map +1 -1
- package/dist/client/engine.js +93 -51
- package/dist/client/engine.js.map +1 -1
- package/dist/client/runs.d.ts +5 -0
- package/dist/client/runs.js +8 -7
- package/dist/client/runs.js.map +1 -1
- package/dist/client/storeTypes.d.ts +22 -1
- package/dist/client/storeTypes.js +27 -0
- package/dist/client/storeTypes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/node/snapshotFileSink.d.ts +3 -0
- package/dist/node/snapshotFileSink.js +79 -0
- package/dist/node/snapshotFileSink.js.map +1 -0
- package/dist/protocol/dtos.d.ts +76 -0
- package/dist/protocol/dtos.js +2 -0
- package/dist/protocol/dtos.js.map +1 -1
- package/dist/protocol/wire.d.ts +3 -1
- package/dist/protocol/wire.js +49 -0
- package/dist/protocol/wire.js.map +1 -1
- package/dist/sqlite/apply.d.ts +21 -1
- package/dist/sqlite/apply.js +218 -114
- package/dist/sqlite/apply.js.map +1 -1
- package/dist/sqlite/connection.d.ts +5 -0
- package/dist/sqlite/connection.js +17 -0
- package/dist/sqlite/connection.js.map +1 -1
- package/dist/sqlite/index.d.ts +2 -1
- package/dist/sqlite/index.js +1 -0
- package/dist/sqlite/index.js.map +1 -1
- package/dist/sqlite/options.d.ts +39 -1
- package/dist/sqlite/options.js +5 -1
- package/dist/sqlite/options.js.map +1 -1
- package/dist/sqlite/push.js +4 -1
- package/dist/sqlite/push.js.map +1 -1
- package/dist/sqlite/query.js +44 -15
- package/dist/sqlite/query.js.map +1 -1
- package/dist/sqlite/revocation.d.ts +1 -1
- package/dist/sqlite/revocation.js +4 -2
- package/dist/sqlite/revocation.js.map +1 -1
- package/dist/sqlite/snapshotFiles.d.ts +51 -0
- package/dist/sqlite/snapshotFiles.js +516 -0
- package/dist/sqlite/snapshotFiles.js.map +1 -0
- package/dist/sqlite/sql.d.ts +1 -1
- package/dist/sqlite/sql.js +4 -0
- package/dist/sqlite/sql.js.map +1 -1
- package/dist/sqlite/store.d.ts +20 -2
- package/dist/sqlite/store.js +147 -78
- package/dist/sqlite/store.js.map +1 -1
- package/package.json +3 -3
package/PARITY.md
CHANGED
|
@@ -13,7 +13,8 @@ it). This file maps every .NET piece to its twin and lists every deliberate diff
|
|
|
13
13
|
| Requests written by TypeScript, parsed by the .NET server's own hub protocols (JSON and Nerdbank MessagePack) and compared with the .NET samples | `test/hub.test.ts` → `test/golden/js-vectors.json` → `JsGoldenVectorsTests.Requests_the_TypeScript_client_writes_deserialize_on_the_server` |
|
|
14
14
|
| The TypeScript engine + store + transport against the real .NET sample server (50,000 rows, push, pokes, reconcile, unsubscribe), over both hub protocols | `test/interop/server.test.ts` (`npm run test:interop`) |
|
|
15
15
|
| The database the TypeScript store writes equals the one the .NET store writes for the same sync: app rows and storage classes, every `g9_*` row, the schema, the stored JSON | `test/interop/server.test.ts` → database parity |
|
|
16
|
-
|
|
|
16
|
+
| A site's first download as a snapshot FILE (ADR-0072) copied by the TypeScript store leaves the database the .NET store leaves (both sites as files, the second merged into the first's table) | `test/interop/server.test.ts` → "a snapshot FILE copied by the TypeScript store" |
|
|
17
|
+
| The same scenario in a Lynx-shaped runtime (no BigInt/TextEncoder/URL/WebSocket, native modules behind a copying bridge), with and without snapshot files through `lynxSnapshotFileSink`'s module methods | `@g9tm/syncdata-lynx` `npm run test:lynxsim` |
|
|
17
18
|
|
|
18
19
|
`G9_UPDATE_GOLDEN=1` rewrites the golden files after an intended change; commit them with the change that needs them.
|
|
19
20
|
|
|
@@ -37,6 +38,7 @@ it). This file maps every .NET piece to its twin and lists every deliberate diff
|
|
|
37
38
|
| `G9CSyncProgressTracker`, `G9DtSyncProgress` | `G9SyncProgressTracker`, `G9DtSyncProgress` (`etaMs`) | same planning, creep and monotonicity |
|
|
38
39
|
| `IG9SyncClientStore` | `G9SyncClientStore` | |
|
|
39
40
|
| `G9CSqliteSyncStore`, options | `G9SqliteSyncStore` (`@g9tm/syncdata/sqlite`) over a `G9SqliteDriver` | same tables, triggers, SQL, JSON blobs and storage forms |
|
|
41
|
+
| Snapshot files (`G9CSqliteSyncStore.SnapshotFiles.cs`): `AcceptSnapshotFiles`, `SnapshotFileDirectory`, checkpoints, staging, verification, the copy/columns/merge apply, the sweep | `snapshotFiles: G9SnapshotFileSink` (`nodeSnapshotFileSink(dir)` in `@g9tm/syncdata/node`, `lynxSnapshotFileSink(db)` in `@g9tm/syncdata-lynx`), `src/sqlite/snapshotFiles.ts` | the same SQL statement for statement; the bytes go through the sink (decompress + SHA-256 + positional write) instead of `RandomAccess` in process; `G9CSnapshotFileWriter` (the server's) has no twin |
|
|
40
42
|
| `EnqueueCommand(sqlite3 appConnection, …)` | `enqueueCommandOnSession({ driver, session, tx }, …)` | SAVEPOINT inside the app's transaction |
|
|
41
43
|
| `IncarnationMarkerPath` | `incarnationMarker` (`fileIncarnationMarker(fileSystem, path)`) | any `G9FileSystem` (Lynx: the SignalR native module's) |
|
|
42
44
|
| `ExportPendingChangesAsync(path)` | `exportPendingChanges(writeLine)` | the caller writes the lines where it wants |
|
|
@@ -60,7 +62,29 @@ it). This file maps every .NET piece to its twin and lists every deliberate diff
|
|
|
60
62
|
released rows in bulk (`disposeReleasedRows`): metadata, pending commands and app rows read in three queries, the
|
|
61
63
|
revocation policy deciding in the .NET store's order, writes batched with each row's explicit `local_seq`, the clock
|
|
62
64
|
moved once. Same database (`test/revocation.test.ts` against the row-by-row path, interop parity against .NET); row
|
|
63
|
-
by row while a foreign trigger exists. A move-
|
|
65
|
+
by row while a foreign trigger exists. A run of move-outs (a MoveOut section of a pull, a reconciliation's move-out
|
|
66
|
+
keys) goes the same way (`moveOutMany`): every membership update queued and flushed once, then the released rows
|
|
67
|
+
disposed in bulk — same database as `moveOut` row by row (`test/moveout.test.ts`); row by row for an ephemeral
|
|
68
|
+
subscription, a Keep policy, duplicate keys or a foreign trigger.
|
|
69
|
+
- **Provisioning.** `initialize` reads everything its decisions need (g9_tables, the tables and their columns, the
|
|
70
|
+
triggers, the AUTOINCREMENT counter) in ONE driver call and runs the resulting statements in ONE batch, in the order
|
|
71
|
+
the .NET store's per-table `ProvisionTable` runs them; a new table gets its tid explicitly (the value AUTOINCREMENT
|
|
72
|
+
would hand out, in model order) so its triggers are written in the same batch. Same objects, rows, tids and counter
|
|
73
|
+
(`test/provision.test.ts` compares both paths on fresh, reopened, evolved and repaired databases). AgriPad's 47 tables:
|
|
74
|
+
14 driver calls on every start instead of 296, 17 instead of 534 on the first.
|
|
75
|
+
- **Per-run bookkeeping.** `saveHello` writes the meta rows, the definitions' move-out policies and every negotiated
|
|
76
|
+
table (with its capture triggers) in ONE batch, after one read for the only per-table question (does a table with
|
|
77
|
+
newly negotiated columns hold rows?); the rows an apply protects (`readRowsChangedAfter`) are read with one
|
|
78
|
+
`UNION ALL` statement instead of a query per table. Same database (`test/hello.test.ts`). AgriPad: a no-change sync
|
|
79
|
+
of a site went from 216 driver calls to ~40.
|
|
80
|
+
- **Staging a chunk.** One atomic driver call (`runAtomic`) instead of begin + insert + commit, and the connection's
|
|
81
|
+
journal mode and `synchronous` read once per connection rather than per chunk: 3 calls per chunk instead of ~7
|
|
82
|
+
(`test/staging.test.ts`). Same rows, same durability window.
|
|
83
|
+
- **Snapshot-file bytes.** The .NET store decompresses and writes segments in process; the TypeScript store hands each
|
|
84
|
+
compressed segment to its `G9SnapshotFileSink` and never sees the decoded bytes (on Lynx they never cross the bridge).
|
|
85
|
+
The sink also answers the store's size/header checks. Without a sink (`snapshotFiles: null`, the default) the store
|
|
86
|
+
offers no files and the server sends rows. Everything the store does in SQL - the checkpoints, the copy, `g9_rows`,
|
|
87
|
+
the merge rules, the sweep - is the .NET store's.
|
|
64
88
|
- **Two sessions.** A writer session under the store's lock and a reader session, instead of a pool.
|
|
65
89
|
- **Reading values to push.** Decimal and String columns are selected as `CAST(… AS TEXT)`: exactly the text .NET's
|
|
66
90
|
`GetString` / `GetDecimal` parse.
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ local database** as the .NET client — a database the MAUI app wrote opens in t
|
|
|
14
14
|
| `@g9tm/syncdata/testing` | fault injection over any transport (`G9FaultInjectingTransport`) |
|
|
15
15
|
| `@g9tm/syncdata/brotli-js` | a pure-JavaScript Brotli decoder for runtimes without a native one |
|
|
16
16
|
|
|
17
|
-
Version 1.2.
|
|
17
|
+
Version 1.2.2 — always equal to the .NET packages' version. What maps to what, and every deliberate difference:
|
|
18
18
|
[PARITY.md](PARITY.md).
|
|
19
19
|
|
|
20
20
|
## Install
|
|
@@ -35,6 +35,10 @@ export declare const G9SyncErrorCodes: {
|
|
|
35
35
|
readonly ConflictColumns: "G9S-CON-COLUMNS";
|
|
36
36
|
readonly ConflictDeleted: "G9S-CON-DELETED";
|
|
37
37
|
readonly ConflictResolverRejected: "G9S-CON-RESOLVER";
|
|
38
|
+
/** The server could not build or serve a planned snapshot file (ADR-0072); the client replans the pull as rows. */
|
|
39
|
+
readonly SnapshotFileUnavailable: "G9S-FILE-UNAVAILABLE";
|
|
40
|
+
/** A downloaded snapshot file failed the device's checks; nothing of it was applied and the pull is replanned as rows. */
|
|
41
|
+
readonly SnapshotFileCorrupt: "G9S-FILE-CORRUPT";
|
|
38
42
|
readonly LocalStoreError: "G9S-LOCAL-STORE";
|
|
39
43
|
/** The device database is damaged or not a database (ADR-0068). Export what is readable and rebuild from the server. */
|
|
40
44
|
readonly LocalStoreCorrupt: "G9S-LOCAL-CORRUPT";
|
|
@@ -37,6 +37,10 @@ export const G9SyncErrorCodes = {
|
|
|
37
37
|
ConflictColumns: 'G9S-CON-COLUMNS',
|
|
38
38
|
ConflictDeleted: 'G9S-CON-DELETED',
|
|
39
39
|
ConflictResolverRejected: 'G9S-CON-RESOLVER',
|
|
40
|
+
/** The server could not build or serve a planned snapshot file (ADR-0072); the client replans the pull as rows. */
|
|
41
|
+
SnapshotFileUnavailable: 'G9S-FILE-UNAVAILABLE',
|
|
42
|
+
/** A downloaded snapshot file failed the device's checks; nothing of it was applied and the pull is replanned as rows. */
|
|
43
|
+
SnapshotFileCorrupt: 'G9S-FILE-CORRUPT',
|
|
40
44
|
LocalStoreError: 'G9S-LOCAL-STORE',
|
|
41
45
|
/** The device database is damaged or not a database (ADR-0068). Export what is readable and rebuild from the server. */
|
|
42
46
|
LocalStoreCorrupt: 'G9S-LOCAL-CORRUPT',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/abstractions/errors.ts"],"names":[],"mappings":"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","sourcesContent":["// Twins of G9SyncData.Abstractions/G9SyncErrorCodes.cs and G9CSyncException.cs. A code is a public API: never\n// repurpose one, only add — and add it on both sides in the same change.\n\n/** Stable, support-facing error codes. The client classifies failures by these codes, never by message text. */\nexport const G9SyncErrorCodes = {\n NetworkFailure: 'G9S-NET-FAILURE',\n ServerError: 'G9S-SRV-ERROR',\n Canceled: 'G9S-CANCELED',\n\n Unauthorized: 'G9S-AUTH-401',\n UpgradeRequired: 'G9S-VER-UPGRADE',\n ProtocolMismatch: 'G9S-VER-PROTOCOL',\n\n ModelInvalid: 'G9S-MODEL-INVALID',\n SchemaUpgradeRequired: 'G9S-SCH-UPGRADE',\n UnknownTable: 'G9S-SCH-UNKNOWN-TABLE',\n /** A column declared with a type the server's column does not have (ADR-0061): it syncs in neither direction. */\n ModelTypeMismatch: 'G9S-MODEL-TYPE-MISMATCH',\n /** The server is at its limit of concurrent pulls or pushes (ADR-0062). Retryable. */\n ServerBusy: 'G9S-SRV-BUSY',\n\n UnknownSubscription: 'G9S-SUB-UNKNOWN',\n InvalidParameters: 'G9S-SUB-PARAM',\n SubscriptionForbidden: 'G9S-SUB-FORBIDDEN',\n\n ResyncEpoch: 'G9S-RSY-EPOCH',\n ResyncHorizon: 'G9S-RSY-HORIZON',\n ResyncAuthChanged: 'G9S-RSY-AUTH',\n TokenInvalid: 'G9S-TOKEN-INVALID',\n TokenExpired: 'G9S-TOKEN-EXPIRED',\n\n RejectValidation: 'G9S-REJ-VALIDATION',\n RejectConstraint: 'G9S-REJ-CONSTRAINT',\n RejectPermission: 'G9S-REJ-PERMISSION',\n RejectDirection: 'G9S-REJ-DIRECTION',\n RejectTooMany: 'G9S-REJ-TOO-MANY',\n /** One row is larger than the server accepts in a push: rejected on the device, every other row still uploads. */\n RejectTooLarge: 'G9S-REJ-TOO-LARGE',\n RejectMalformed: 'G9S-REJ-MALFORMED',\n\n CommandUnknown: 'G9S-CMD-UNKNOWN',\n CommandRejected: 'G9S-CMD-REJECTED',\n\n ConflictServerWins: 'G9S-CON-SERVER-WINS',\n ConflictColumns: 'G9S-CON-COLUMNS',\n ConflictDeleted: 'G9S-CON-DELETED',\n ConflictResolverRejected: 'G9S-CON-RESOLVER',\n\n LocalStoreError: 'G9S-LOCAL-STORE',\n /** The device database is damaged or not a database (ADR-0068). Export what is readable and rebuild from the server. */\n LocalStoreCorrupt: 'G9S-LOCAL-CORRUPT',\n /** The device ran out of storage while the store was writing. */\n LocalStoreDiskFull: 'G9S-LOCAL-DISK-FULL',\n /** Another connection held the write lock longer than the busy timeout. Retryable. */\n LocalStoreBusy: 'G9S-LOCAL-BUSY',\n\n Unknown: 'G9S-UNKNOWN',\n} as const;\n\nconst RETRYABLE: { [code: string]: true } = {\n 'G9S-NET-FAILURE': true,\n 'G9S-SRV-ERROR': true,\n 'G9S-SRV-BUSY': true,\n 'G9S-TOKEN-EXPIRED': true,\n 'G9S-RSY-EPOCH': true,\n 'G9S-RSY-HORIZON': true,\n 'G9S-RSY-AUTH': true,\n 'G9S-LOCAL-BUSY': true,\n};\n\n/** True for failures a later attempt may succeed at without any change to data or code (`G9SyncErrorCodes.IsRetryable`). */\nexport function isRetryableCode(code: string): boolean {\n return RETRYABLE[code] === true;\n}\n\n/** Options of {@link G9SyncException}. */\nexport interface G9SyncExceptionOptions {\n /** Overrides the default retryability of the code. */\n retryable?: boolean;\n /** Structured detail (JSON or text) for logs. Never shown to end users. */\n details?: string | null;\n /** The underlying failure. */\n cause?: unknown;\n}\n\n/**\n * The single error type G9SyncData throws for expected failures (twin of `G9CSyncException`). Always carries a stable\n * {@link code}; callers switch on the code, never on the message.\n */\nexport class G9SyncException extends Error {\n /** Stable error code (see {@link G9SyncErrorCodes}). */\n readonly code: string;\n /** True when retrying without changes may succeed. */\n readonly retryable: boolean;\n /** Optional structured detail (JSON or text) for logs. */\n readonly details: string | null;\n\n constructor(code: string, message: string, options?: G9SyncExceptionOptions) {\n super(message);\n this.name = 'G9SyncException';\n this.code = code;\n this.retryable = options?.retryable ?? isRetryableCode(code);\n this.details = options?.details ?? null;\n if (options && 'cause' in options) {\n Object.defineProperty(this, 'cause', { value: options.cause, enumerable: false, writable: true, configurable: true });\n }\n // ES2019 output: keep instanceof working for subclasses compiled to functions.\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** True for a {@link G9SyncException} (also across bundles that each carry a copy of the class). */\nexport function isSyncException(error: unknown): error is G9SyncException {\n return (\n error instanceof G9SyncException ||\n (typeof error === 'object' &&\n error !== null &&\n (error as { name?: unknown }).name === 'G9SyncException' &&\n typeof (error as { code?: unknown }).code === 'string')\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/abstractions/errors.ts"],"names":[],"mappings":"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","sourcesContent":["// Twins of G9SyncData.Abstractions/G9SyncErrorCodes.cs and G9CSyncException.cs. A code is a public API: never\n// repurpose one, only add — and add it on both sides in the same change.\n\n/** Stable, support-facing error codes. The client classifies failures by these codes, never by message text. */\nexport const G9SyncErrorCodes = {\n NetworkFailure: 'G9S-NET-FAILURE',\n ServerError: 'G9S-SRV-ERROR',\n Canceled: 'G9S-CANCELED',\n\n Unauthorized: 'G9S-AUTH-401',\n UpgradeRequired: 'G9S-VER-UPGRADE',\n ProtocolMismatch: 'G9S-VER-PROTOCOL',\n\n ModelInvalid: 'G9S-MODEL-INVALID',\n SchemaUpgradeRequired: 'G9S-SCH-UPGRADE',\n UnknownTable: 'G9S-SCH-UNKNOWN-TABLE',\n /** A column declared with a type the server's column does not have (ADR-0061): it syncs in neither direction. */\n ModelTypeMismatch: 'G9S-MODEL-TYPE-MISMATCH',\n /** The server is at its limit of concurrent pulls or pushes (ADR-0062). Retryable. */\n ServerBusy: 'G9S-SRV-BUSY',\n\n UnknownSubscription: 'G9S-SUB-UNKNOWN',\n InvalidParameters: 'G9S-SUB-PARAM',\n SubscriptionForbidden: 'G9S-SUB-FORBIDDEN',\n\n ResyncEpoch: 'G9S-RSY-EPOCH',\n ResyncHorizon: 'G9S-RSY-HORIZON',\n ResyncAuthChanged: 'G9S-RSY-AUTH',\n TokenInvalid: 'G9S-TOKEN-INVALID',\n TokenExpired: 'G9S-TOKEN-EXPIRED',\n\n RejectValidation: 'G9S-REJ-VALIDATION',\n RejectConstraint: 'G9S-REJ-CONSTRAINT',\n RejectPermission: 'G9S-REJ-PERMISSION',\n RejectDirection: 'G9S-REJ-DIRECTION',\n RejectTooMany: 'G9S-REJ-TOO-MANY',\n /** One row is larger than the server accepts in a push: rejected on the device, every other row still uploads. */\n RejectTooLarge: 'G9S-REJ-TOO-LARGE',\n RejectMalformed: 'G9S-REJ-MALFORMED',\n\n CommandUnknown: 'G9S-CMD-UNKNOWN',\n CommandRejected: 'G9S-CMD-REJECTED',\n\n ConflictServerWins: 'G9S-CON-SERVER-WINS',\n ConflictColumns: 'G9S-CON-COLUMNS',\n ConflictDeleted: 'G9S-CON-DELETED',\n ConflictResolverRejected: 'G9S-CON-RESOLVER',\n\n /** The server could not build or serve a planned snapshot file (ADR-0072); the client replans the pull as rows. */\n SnapshotFileUnavailable: 'G9S-FILE-UNAVAILABLE',\n /** A downloaded snapshot file failed the device's checks; nothing of it was applied and the pull is replanned as rows. */\n SnapshotFileCorrupt: 'G9S-FILE-CORRUPT',\n LocalStoreError: 'G9S-LOCAL-STORE',\n /** The device database is damaged or not a database (ADR-0068). Export what is readable and rebuild from the server. */\n LocalStoreCorrupt: 'G9S-LOCAL-CORRUPT',\n /** The device ran out of storage while the store was writing. */\n LocalStoreDiskFull: 'G9S-LOCAL-DISK-FULL',\n /** Another connection held the write lock longer than the busy timeout. Retryable. */\n LocalStoreBusy: 'G9S-LOCAL-BUSY',\n\n Unknown: 'G9S-UNKNOWN',\n} as const;\n\nconst RETRYABLE: { [code: string]: true } = {\n 'G9S-NET-FAILURE': true,\n 'G9S-SRV-ERROR': true,\n 'G9S-SRV-BUSY': true,\n 'G9S-TOKEN-EXPIRED': true,\n 'G9S-RSY-EPOCH': true,\n 'G9S-RSY-HORIZON': true,\n 'G9S-RSY-AUTH': true,\n 'G9S-LOCAL-BUSY': true,\n};\n\n/** True for failures a later attempt may succeed at without any change to data or code (`G9SyncErrorCodes.IsRetryable`). */\nexport function isRetryableCode(code: string): boolean {\n return RETRYABLE[code] === true;\n}\n\n/** Options of {@link G9SyncException}. */\nexport interface G9SyncExceptionOptions {\n /** Overrides the default retryability of the code. */\n retryable?: boolean;\n /** Structured detail (JSON or text) for logs. Never shown to end users. */\n details?: string | null;\n /** The underlying failure. */\n cause?: unknown;\n}\n\n/**\n * The single error type G9SyncData throws for expected failures (twin of `G9CSyncException`). Always carries a stable\n * {@link code}; callers switch on the code, never on the message.\n */\nexport class G9SyncException extends Error {\n /** Stable error code (see {@link G9SyncErrorCodes}). */\n readonly code: string;\n /** True when retrying without changes may succeed. */\n readonly retryable: boolean;\n /** Optional structured detail (JSON or text) for logs. */\n readonly details: string | null;\n\n constructor(code: string, message: string, options?: G9SyncExceptionOptions) {\n super(message);\n this.name = 'G9SyncException';\n this.code = code;\n this.retryable = options?.retryable ?? isRetryableCode(code);\n this.details = options?.details ?? null;\n if (options && 'cause' in options) {\n Object.defineProperty(this, 'cause', { value: options.cause, enumerable: false, writable: true, configurable: true });\n }\n // ES2019 output: keep instanceof working for subclasses compiled to functions.\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** True for a {@link G9SyncException} (also across bundles that each carry a copy of the class). */\nexport function isSyncException(error: unknown): error is G9SyncException {\n return (\n error instanceof G9SyncException ||\n (typeof error === 'object' &&\n error !== null &&\n (error as { name?: unknown }).name === 'G9SyncException' &&\n typeof (error as { code?: unknown }).code === 'string')\n );\n}\n"]}
|
package/dist/client/engine.js
CHANGED
|
@@ -7,7 +7,7 @@ import { G9SyncParameters, compareOrdinal } from '../abstractions/parameters.js'
|
|
|
7
7
|
import { delay, linkSignals, onAbort } from '../internal/abort.js';
|
|
8
8
|
import { sha256, toHex } from '../internal/sha256.js';
|
|
9
9
|
import { utf8Encode } from '../internal/utf8.js';
|
|
10
|
-
import { G9ESubscriptionAction, syncErrorToException, } from '../protocol/dtos.js';
|
|
10
|
+
import { G9EPullPartKind, G9ESubscriptionAction, syncErrorToException, } from '../protocol/dtos.js';
|
|
11
11
|
import { openRowBatch } from '../protocol/rowBatch/reader.js';
|
|
12
12
|
import { G9ESyncPhase, G9SyncProgressTracker, phaseName } from './progress.js';
|
|
13
13
|
import { G9ESyncOutcome, G9ESyncPriority, G9SyncRun, canCoalesce, describeModelProblem, isTransientError, makeRunResult, normalizeError, outcomeName, priorityName, requestSignature, resolveEngineOptions, resolveRequest, } from './runs.js';
|
|
@@ -570,53 +570,85 @@ export class G9SyncEngine {
|
|
|
570
570
|
const pruned = await this._store.pruneStagedPulls(Date.now() - this._options.stagedPullMaxAgeMs, this._options.stagedPullsMaxBytes, generation, ct);
|
|
571
571
|
if (pruned > 0)
|
|
572
572
|
this._log('information', 20021, `Removed ${pruned} staged pull(s) past their age or the byte cap`);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
let
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
573
|
+
// Snapshot files are offered unless one already failed in this run (ADR-0072): a file is a faster way to the same rows,
|
|
574
|
+
// never the only one, so a file the server cannot build or the device rejects costs one replan as rows - not the run.
|
|
575
|
+
let allowFiles = this._options.acceptSnapshotFiles;
|
|
576
|
+
for (let attempt = 0;; attempt++) {
|
|
577
|
+
const problemsBefore = problems.length;
|
|
578
|
+
const syncedBefore = synced.length;
|
|
579
|
+
let runId = null;
|
|
580
|
+
try {
|
|
581
|
+
const staged = await this._store.getStagedPullForGeneration(generation, ct);
|
|
582
|
+
let plan;
|
|
583
|
+
if (staged && staged.createdUtcMs > Date.now() - this._options.stagedPullMaxAgeMs) {
|
|
584
|
+
plan = staged.plan;
|
|
585
|
+
runId = staged.runId;
|
|
586
|
+
this._log('information', 20012, `Resuming staged pull ${runId} (${plan.totalRows} rows planned)`);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
const backfills = await this._store.getPendingBackfills(ct);
|
|
590
|
+
const files = allowFiles && this._store.describeSnapshotFiles ? this._store.describeSnapshotFiles(negotiated) : null;
|
|
591
|
+
plan = await this._withRetry((token) => this._transport.planPull({
|
|
592
|
+
clientId: this._clientId,
|
|
593
|
+
subscriptions: plannable.map(toSubscriptionRef),
|
|
594
|
+
tables: negotiated.map((t) => ({ tableId: t.tableId, columns: t.columns.map((c) => c.name) })),
|
|
595
|
+
backfills: backfills.slice(),
|
|
596
|
+
frameVersion: this._options.maxFrameVersion,
|
|
597
|
+
...(files ? { snapshotFiles: files } : {}),
|
|
598
|
+
}, token), ct);
|
|
599
|
+
if (plan.error)
|
|
600
|
+
throw syncErrorToException(plan.error);
|
|
601
|
+
runId = guidToN(newGuid());
|
|
602
|
+
const plannedFromCursors = {};
|
|
603
|
+
for (const s of plannable)
|
|
604
|
+
plannedFromCursors[s.key] = s.cursor;
|
|
605
|
+
await this._store.beginStagedPull({ runId, plan, createdUtcMs: Date.now(), backfills, generation, plannedFromCursors, stageSequence: 0 }, ct);
|
|
606
|
+
}
|
|
607
|
+
for (const skipped of plan.skipped) {
|
|
608
|
+
problems.push(skipped);
|
|
609
|
+
if (skipped.action === G9ESubscriptionAction.Reconcile || skipped.action === G9ESubscriptionAction.ResyncRequired) {
|
|
610
|
+
await this._store.updateSubscription(skipped.key, { state: G9ESubscriptionState.NeedsReconcile }, ct);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
for (const c of plan.cursors)
|
|
614
|
+
synced.push(c.key);
|
|
615
|
+
const fileParts = plan.parts.filter((p) => p.kind === G9EPullPartKind.SnapshotFile);
|
|
616
|
+
if (fileParts.length > 0) {
|
|
617
|
+
this._log('information', 20025, `The pull carries ${fileParts.length} snapshot file(s) for ${fileParts.reduce((sum, p) => sum + p.rows, 0)} row(s)`);
|
|
618
|
+
}
|
|
619
|
+
tracker.expect(G9ESyncPhase.Downloading, Math.max(200, (plan.totalRows / 1000) * historyOf(history, STAT_DOWNLOAD_PER_K_ROW, 350)));
|
|
620
|
+
tracker.expect(G9ESyncPhase.Applying, Math.max(100, (plan.totalRows / 1000) * historyOf(history, STAT_APPLY_PER_K_ROW, 250)));
|
|
621
|
+
// ------------------------------------------------------------ 6. download (resumable)
|
|
622
|
+
tracker.enter(G9ESyncPhase.Downloading, plan.totalRows, plan.estimatedBytes);
|
|
623
|
+
const totals = await this._download(runId, plan, tracker, ct);
|
|
624
|
+
downloaded = totals.rows;
|
|
625
|
+
downloadedBytes = totals.bytes;
|
|
626
|
+
// ------------------------------------------------------------ 7. apply (atomic, or in committed slices)
|
|
627
|
+
tracker.enter(G9ESyncPhase.Applying, downloaded);
|
|
628
|
+
const applied = await this._store.applyStagedPull(runId, (rows) => tracker.report(rows), (slice) => {
|
|
629
|
+
if (slice.report.total > 0)
|
|
630
|
+
this._raiseRowsApplied(slice.report, G9ESyncPhase.Applying, slice);
|
|
631
|
+
}, ct);
|
|
632
|
+
changes.merge(applied);
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
catch (exception) {
|
|
636
|
+
if (attempt === 0 &&
|
|
637
|
+
!ct.aborted &&
|
|
638
|
+
isSyncException(exception) &&
|
|
639
|
+
(exception.code === G9SyncErrorCodes.SnapshotFileUnavailable || exception.code === G9SyncErrorCodes.SnapshotFileCorrupt)) {
|
|
640
|
+
this._log('warning', 20026, `A snapshot file failed (${exception.code}); the staged download was discarded and the pull is planned again as rows`, exception);
|
|
641
|
+
if (runId !== null)
|
|
642
|
+
await this._store.discardStagedPull(runId);
|
|
643
|
+
problems.length = problemsBefore;
|
|
644
|
+
synced.length = syncedBefore;
|
|
645
|
+
allowFiles = false;
|
|
646
|
+
tracker.enter(G9ESyncPhase.Planning);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
throw exception;
|
|
602
650
|
}
|
|
603
651
|
}
|
|
604
|
-
for (const c of plan.cursors)
|
|
605
|
-
synced.push(c.key);
|
|
606
|
-
tracker.expect(G9ESyncPhase.Downloading, Math.max(200, (plan.totalRows / 1000) * historyOf(history, STAT_DOWNLOAD_PER_K_ROW, 350)));
|
|
607
|
-
tracker.expect(G9ESyncPhase.Applying, Math.max(100, (plan.totalRows / 1000) * historyOf(history, STAT_APPLY_PER_K_ROW, 250)));
|
|
608
|
-
// ------------------------------------------------------------ 6. download (resumable)
|
|
609
|
-
tracker.enter(G9ESyncPhase.Downloading, plan.totalRows, plan.estimatedBytes);
|
|
610
|
-
const totals = await this._download(runId, plan, tracker, ct);
|
|
611
|
-
downloaded = totals.rows;
|
|
612
|
-
downloadedBytes = totals.bytes;
|
|
613
|
-
// ------------------------------------------------------------ 7. apply (atomic, or in committed slices)
|
|
614
|
-
tracker.enter(G9ESyncPhase.Applying, downloaded);
|
|
615
|
-
const applied = await this._store.applyStagedPull(runId, (rows) => tracker.report(rows), (slice) => {
|
|
616
|
-
if (slice.report.total > 0)
|
|
617
|
-
this._raiseRowsApplied(slice.report, G9ESyncPhase.Applying, slice);
|
|
618
|
-
}, ct);
|
|
619
|
-
changes.merge(applied);
|
|
620
652
|
}
|
|
621
653
|
// ---------------------------------------------------------------- 8. post steps
|
|
622
654
|
for (const step of request.postSteps) {
|
|
@@ -832,14 +864,23 @@ export class G9SyncEngine {
|
|
|
832
864
|
}
|
|
833
865
|
async _download(runId, plan, tracker, ct) {
|
|
834
866
|
var _a;
|
|
835
|
-
|
|
867
|
+
// maxRetries counts failures in a row WITHOUT progress (the .NET twin, DownloadAsync): progress is the DURABLE resume point
|
|
868
|
+
// moving, not a chunk arriving - segments after the last checkpoint are written again on resume.
|
|
869
|
+
let failures = 0;
|
|
870
|
+
let lastFailedAt = null;
|
|
871
|
+
for (;;) {
|
|
836
872
|
const resume = await this._store.getResumePoint(runId, ct);
|
|
837
873
|
let rows = resume.stagedRows;
|
|
838
874
|
let bytes = resume.stagedBytes;
|
|
839
875
|
if (resume.complete)
|
|
840
876
|
return { rows, bytes };
|
|
877
|
+
if (lastFailedAt !== null && (resume.stagedRows > lastFailedAt.stagedRows || resume.stagedBytes > lastFailedAt.stagedBytes))
|
|
878
|
+
failures = 0;
|
|
841
879
|
tracker.report(rows, bytes);
|
|
842
880
|
try {
|
|
881
|
+
// Inside the try (the .NET twin, DownloadAsync): a reconnect that fails while the network is still down is one more
|
|
882
|
+
// transient failure, not the end of the run.
|
|
883
|
+
await this._ensureConnected(ct);
|
|
843
884
|
for await (const chunk of this._transport.pull(plan.token, resume.continuation, ct)) {
|
|
844
885
|
await this._store.stageChunk(runId, chunk, ct);
|
|
845
886
|
if (chunk.isFinal)
|
|
@@ -859,10 +900,11 @@ export class G9SyncEngine {
|
|
|
859
900
|
await this._store.discardStagedPull(runId);
|
|
860
901
|
throw exception;
|
|
861
902
|
}
|
|
862
|
-
if (!ct.aborted && isTransientError(exception) &&
|
|
863
|
-
this._log('warning', 20013, `Transient pull failure, retry ${
|
|
864
|
-
await delay(this._backoff(
|
|
865
|
-
|
|
903
|
+
if (!ct.aborted && isTransientError(exception) && failures < this._options.maxRetries) {
|
|
904
|
+
this._log('warning', 20013, `Transient pull failure, retry ${failures + 1}: ${messageOf(exception)}`);
|
|
905
|
+
await delay(this._backoff(failures), ct);
|
|
906
|
+
failures++;
|
|
907
|
+
lastFailedAt = resume;
|
|
866
908
|
continue;
|
|
867
909
|
}
|
|
868
910
|
throw exception;
|