@g9tm/syncdata 1.2.2 → 1.3.1

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 (56) hide show
  1. package/PARITY.md +26 -2
  2. package/dist/abstractions/errors.d.ts +4 -0
  3. package/dist/abstractions/errors.js +4 -0
  4. package/dist/abstractions/errors.js.map +1 -1
  5. package/dist/client/engine.js +93 -51
  6. package/dist/client/engine.js.map +1 -1
  7. package/dist/client/runs.d.ts +5 -0
  8. package/dist/client/runs.js +8 -7
  9. package/dist/client/runs.js.map +1 -1
  10. package/dist/client/storeTypes.d.ts +22 -1
  11. package/dist/client/storeTypes.js +27 -0
  12. package/dist/client/storeTypes.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/node/index.d.ts +1 -0
  17. package/dist/node/index.js +1 -0
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/snapshotFileSink.d.ts +3 -0
  20. package/dist/node/snapshotFileSink.js +79 -0
  21. package/dist/node/snapshotFileSink.js.map +1 -0
  22. package/dist/protocol/dtos.d.ts +76 -0
  23. package/dist/protocol/dtos.js +2 -0
  24. package/dist/protocol/dtos.js.map +1 -1
  25. package/dist/protocol/wire.d.ts +3 -1
  26. package/dist/protocol/wire.js +49 -0
  27. package/dist/protocol/wire.js.map +1 -1
  28. package/dist/sqlite/apply.d.ts +21 -1
  29. package/dist/sqlite/apply.js +218 -114
  30. package/dist/sqlite/apply.js.map +1 -1
  31. package/dist/sqlite/connection.d.ts +5 -0
  32. package/dist/sqlite/connection.js +17 -0
  33. package/dist/sqlite/connection.js.map +1 -1
  34. package/dist/sqlite/index.d.ts +2 -1
  35. package/dist/sqlite/index.js +1 -0
  36. package/dist/sqlite/index.js.map +1 -1
  37. package/dist/sqlite/options.d.ts +39 -1
  38. package/dist/sqlite/options.js +5 -1
  39. package/dist/sqlite/options.js.map +1 -1
  40. package/dist/sqlite/push.js +4 -1
  41. package/dist/sqlite/push.js.map +1 -1
  42. package/dist/sqlite/query.js +44 -15
  43. package/dist/sqlite/query.js.map +1 -1
  44. package/dist/sqlite/revocation.d.ts +1 -1
  45. package/dist/sqlite/revocation.js +4 -2
  46. package/dist/sqlite/revocation.js.map +1 -1
  47. package/dist/sqlite/snapshotFiles.d.ts +51 -0
  48. package/dist/sqlite/snapshotFiles.js +516 -0
  49. package/dist/sqlite/snapshotFiles.js.map +1 -0
  50. package/dist/sqlite/sql.d.ts +1 -1
  51. package/dist/sqlite/sql.js +4 -0
  52. package/dist/sqlite/sql.js.map +1 -1
  53. package/dist/sqlite/store.d.ts +20 -2
  54. package/dist/sqlite/store.js +147 -78
  55. package/dist/sqlite/store.js.map +1 -1
  56. 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
- | The same scenario in a Lynx-shaped runtime (no BigInt/TextEncoder/URL/WebSocket, native modules behind a copying bridge) | `@g9tm/syncdata-lynx` `npm run test:lynxsim` |
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-out inside a pull stays row by row.
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.
@@ -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":"AAAA,8GAA8G;AAC9G,yEAAyE;AAEzE,gHAAgH;AAChH,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,cAAc,EAAE,iBAAiB;IACjC,WAAW,EAAE,eAAe;IAC5B,QAAQ,EAAE,cAAc;IAExB,YAAY,EAAE,cAAc;IAC5B,eAAe,EAAE,iBAAiB;IAClC,gBAAgB,EAAE,kBAAkB;IAEpC,YAAY,EAAE,mBAAmB;IACjC,qBAAqB,EAAE,iBAAiB;IACxC,YAAY,EAAE,uBAAuB;IACrC,iHAAiH;IACjH,iBAAiB,EAAE,yBAAyB;IAC5C,sFAAsF;IACtF,UAAU,EAAE,cAAc;IAE1B,mBAAmB,EAAE,iBAAiB;IACtC,iBAAiB,EAAE,eAAe;IAClC,qBAAqB,EAAE,mBAAmB;IAE1C,WAAW,EAAE,eAAe;IAC5B,aAAa,EAAE,iBAAiB;IAChC,iBAAiB,EAAE,cAAc;IACjC,YAAY,EAAE,mBAAmB;IACjC,YAAY,EAAE,mBAAmB;IAEjC,gBAAgB,EAAE,oBAAoB;IACtC,gBAAgB,EAAE,oBAAoB;IACtC,gBAAgB,EAAE,oBAAoB;IACtC,eAAe,EAAE,mBAAmB;IACpC,aAAa,EAAE,kBAAkB;IACjC,kHAAkH;IAClH,cAAc,EAAE,mBAAmB;IACnC,eAAe,EAAE,mBAAmB;IAEpC,cAAc,EAAE,iBAAiB;IACjC,eAAe,EAAE,kBAAkB;IAEnC,kBAAkB,EAAE,qBAAqB;IACzC,eAAe,EAAE,iBAAiB;IAClC,eAAe,EAAE,iBAAiB;IAClC,wBAAwB,EAAE,kBAAkB;IAE5C,mHAAmH;IACnH,uBAAuB,EAAE,sBAAsB;IAC/C,0HAA0H;IAC1H,mBAAmB,EAAE,kBAAkB;IACvC,eAAe,EAAE,iBAAiB;IAClC,wHAAwH;IACxH,iBAAiB,EAAE,mBAAmB;IACtC,iEAAiE;IACjE,kBAAkB,EAAE,qBAAqB;IACzC,sFAAsF;IACtF,cAAc,EAAE,gBAAgB;IAEhC,OAAO,EAAE,aAAa;CACd,CAAC;AAEX,MAAM,SAAS,GAA6B;IAC1C,iBAAiB,EAAE,IAAI;IACvB,eAAe,EAAE,IAAI;IACrB,cAAc,EAAE,IAAI;IACpB,mBAAmB,EAAE,IAAI;IACzB,eAAe,EAAE,IAAI;IACrB,iBAAiB,EAAE,IAAI;IACvB,cAAc,EAAE,IAAI;IACpB,gBAAgB,EAAE,IAAI;CACvB,CAAC;AAEF,4HAA4H;AAC5H,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,SAAS,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC;AAClC,CAAC;AAYD;;;GAGG;AACH,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAQxC,YAAY,IAAY,EAAE,OAAe,EAAE,OAAgC;;QACzE,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,SAAS,GAAG,MAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,SAAS,mCAAI,eAAe,CAAC,IAAI,CAAC,CAAC;QAC7D,IAAI,CAAC,OAAO,GAAG,MAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,OAAO,mCAAI,IAAI,CAAC;QACxC,IAAI,OAAO,IAAI,OAAO,IAAI,OAAO,EAAE,CAAC;YAClC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC;QACxH,CAAC;QACD,+EAA+E;QAC/E,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACpD,CAAC;CACF;AAED,oGAAoG;AACpG,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,OAAO,CACL,KAAK,YAAY,eAAe;QAChC,CAAC,OAAO,KAAK,KAAK,QAAQ;YACxB,KAAK,KAAK,IAAI;YACb,KAA4B,CAAC,IAAI,KAAK,iBAAiB;YACxD,OAAQ,KAA4B,CAAC,IAAI,KAAK,QAAQ,CAAC,CAC1D,CAAC;AACJ,CAAC","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"]}
@@ -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
- const staged = await this._store.getStagedPullForGeneration(generation, ct);
574
- let plan;
575
- let runId;
576
- if (staged && staged.createdUtcMs > Date.now() - this._options.stagedPullMaxAgeMs) {
577
- plan = staged.plan;
578
- runId = staged.runId;
579
- this._log('information', 20012, `Resuming staged pull ${runId} (${plan.totalRows} rows planned)`);
580
- }
581
- else {
582
- const backfills = await this._store.getPendingBackfills(ct);
583
- plan = await this._withRetry((token) => this._transport.planPull({
584
- clientId: this._clientId,
585
- subscriptions: plannable.map(toSubscriptionRef),
586
- tables: negotiated.map((t) => ({ tableId: t.tableId, columns: t.columns.map((c) => c.name) })),
587
- backfills: backfills.slice(),
588
- frameVersion: this._options.maxFrameVersion,
589
- }, token), ct);
590
- if (plan.error)
591
- throw syncErrorToException(plan.error);
592
- runId = guidToN(newGuid());
593
- const plannedFromCursors = {};
594
- for (const s of plannable)
595
- plannedFromCursors[s.key] = s.cursor;
596
- await this._store.beginStagedPull({ runId, plan, createdUtcMs: Date.now(), backfills, generation, plannedFromCursors, stageSequence: 0 }, ct);
597
- }
598
- for (const skipped of plan.skipped) {
599
- problems.push(skipped);
600
- if (skipped.action === G9ESubscriptionAction.Reconcile || skipped.action === G9ESubscriptionAction.ResyncRequired) {
601
- await this._store.updateSubscription(skipped.key, { state: G9ESubscriptionState.NeedsReconcile }, ct);
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
- for (let attempt = 0;; attempt++) {
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) && attempt < this._options.maxRetries) {
863
- this._log('warning', 20013, `Transient pull failure, retry ${attempt + 1}: ${messageOf(exception)}`);
864
- await delay(this._backoff(attempt), ct);
865
- await this._ensureConnected(ct);
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;