@g9tm/syncdata 1.5.19 → 1.5.21

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 CHANGED
@@ -48,6 +48,8 @@ it). This file maps every .NET piece to its twin and lists every deliberate diff
48
48
  | A value its type cannot carry refuses only its row, on the device (LES-0088) | `findUnencodable` | `FindUnencodable`: the writer's conversions run before a row enters the batch; a failure is `G9S-REJ-MALFORMED` with the column, counted in `rejectedLocally`; `MalformedLocalValueTests` / `test/malformedLocalValue.test.ts` |
49
49
  | Lost = pushed columns only; timestamps as instants (LES-0086) | `pushedMask`, `sameValue(…, type)` / `instant` | `PushedMask` / `SameValue(type, …)` / `Instant`: the dirty mask restricted to the batch's columns; DateTime/DateTimeOffset text in either form equal within one microsecond (TS: no BigInt - milliseconds plus 100 ns units); `HeldValueTests` / `test/heldValues.test.ts` |
50
50
  | `SaveHelloAsync(hello, localMidpointUtc, ct)` (LES-0075) | `saveHello(hello, signal, localMidpointMs)` | the engine measures the Hello round trip in both clients and stores `serverTime - midpoint` |
51
+ | `RefetchRowsAsync(table, keys?)` (1.5.19, LES-0090) | `refetchRows(table, keys?)` | the same rows (clean, held: `dirty = 0 AND rejected = 0 AND deleted = 0 AND subs <> 0`) get `server_ver = -1`, their holders NeedsReconcile; the holder bitmask of .NET is one `EXISTS … (subs >> slot) & 1` statement; keys canonicalised, pages of 5000; `test/refetchAndRelease.test.ts` |
52
+ | `ReleaseUnclaimedRowsAsync(table, keys?, includePending)` (1.5.19) | `releaseUnclaimedRows(table, keys?, includePending)` | the same selection (`subs = 0 AND server_ver = 0`, not revoked / quarantined / ephemeral / in a pending command; pending and refused only with `includePending`), read whole before the disposal (`temp.g9_unclaimed`, key order), capture off, reason `G9ERevocationReason.Unclaimed` (4), `changedKeys` capped by `maxChangedKeysPerTable`, RowsApplied (PostStep) when anything left; `test/refetchAndRelease.test.ts`, `test/engine.test.ts`. Until 1.5.20 both were .NET only (1.5.19 shipped without the twins) |
51
53
  | `G9CSqliteException` (`IG9SyncLocalStoreFailure`) | `G9SqliteStoreError` (`localStoreFailure`) | classified from the driver's code |
52
54
  | `G9CSignalRSyncTransport`, options | `G9SignalRSyncTransport` (`@g9tm/syncdata/signalr`) | over `G9Client` of `@g9tm/signalr-supernetcore-client`; Lynx: `createLynxSyncTransport`. Limits (ADR-0074): `CallTimeout` / `PlanTimeout` / `PushTimeout` / `StreamIdleTimeout` = `callTimeoutMs` / `planTimeoutMs` / `pushTimeoutMs` / `streamIdleTimeoutMs`, same defaults, same retryable `G9S-NET-FAILURE`, same connection drop (`SignalRTimeoutTests` / `test/transportTimeouts.test.ts`). After an automatic reconnect both resend the last accepted Hello so the new connection rejoins its poke groups (LES-0087: `OnReconnectedAsync` / `_rejoin`; `SignalRTransportTests.After_the_connection_reconnects_by_itself_…` / `test/transportRejoin.test.ts`) |
53
55
  | `G9CInMemorySyncTransport`, `G9CTransportFaults` | `G9FaultInjectingTransport`, `G9TransportFaults` (`@g9tm/syncdata/testing`) | wraps a real transport (no in-process server engine in JavaScript) |
@@ -77,6 +79,13 @@ it). This file maps every .NET piece to its twin and lists every deliberate diff
77
79
  would hand out, in model order) so its triggers are written in the same batch. Same objects, rows, tids and counter
78
80
  (`test/provision.test.ts` compares both paths on fresh, reopened, evolved and repaired databases). AgriPad's 47 tables:
79
81
  14 driver calls on every start instead of 296, 17 instead of 534 on the first.
82
+ - **Hello sends the model once per connection (1.5.21).** `G9CSignalRSyncTransport.HelloAsync` / `G9SignalRSyncTransport.hello`
83
+ strip the tables on a later Hello of the same connection (`_modelOnConnection`, keyed by the connection id and the engine's one
84
+ model list/array) and resend them inside the call on `ModelRequired`; the engines keep the last answer in memory
85
+ (`G9CSyncEngine.HelloCache` / `_lastAnswer`), send `KnownTablesHash` / `KnownDefinitionsHash` and put back what an "unchanged"
86
+ answer left out (`RestoreUnchanged` / `restoreUnchanged`); both stores skip `definitions_json`, `tables_json` and the per-table
87
+ updates of an unchanged answer. Tests: `HelloModelOnceTests` / `test/transportModelOnce.test.ts`; the golden Hello samples carry
88
+ the six new fields.
80
89
  - **Per-run bookkeeping.** `saveHello` writes the meta rows, the definitions' move-out policies and every negotiated
81
90
  table (with its capture triggers) in ONE batch, after one read for the only per-table question (does a table with
82
91
  newly negotiated columns hold rows?); the rows an apply protects (`readRowsChangedAfter`) are read with one
@@ -2,7 +2,7 @@ import { G9SyncParameters } from '../abstractions/parameters.js';
2
2
  import { type G9DtHelloResult, type G9DtNegotiatedTable, type G9DtSubscriptionState } from '../protocol/dtos.js';
3
3
  import type { G9SyncClientModel } from './model.js';
4
4
  import { G9SyncRun, type G9RowsAppliedEvent, type G9SubscriptionOptions, type G9SyncEngineOptions, type G9SyncHealth, type G9SyncRequest, type G9SyncRunResult } from './runs.js';
5
- import { type G9ChangeFeedEntry, type G9ChangeFeedKeyFilter, type G9LocalCommand, type G9LocalCommandWriter, type G9ELocalCommandState, type G9PendingSummary, type G9QuarantinedRow, type G9RetainedRow, type G9SyncClientStore, type G9SyncRejection, type G9SyncSubscriptionRecord } from './storeTypes.js';
5
+ import { G9ApplyReport, type G9ChangeFeedEntry, type G9ChangeFeedKeyFilter, type G9LocalCommand, type G9LocalCommandWriter, type G9ELocalCommandState, type G9PendingSummary, type G9QuarantinedRow, type G9RetainedRow, type G9SyncClientStore, type G9SyncRejection, type G9SyncSubscriptionRecord } from './storeTypes.js';
6
6
  import type { G9SyncTransport } from './transport.js';
7
7
  /**
8
8
  * The client sync engine (`G9CSyncEngine`): one per local database. Owns a prioritised run queue, so status queries
@@ -32,6 +32,13 @@ export declare class G9SyncEngine {
32
32
  private _disposed;
33
33
  /** The model problems last logged, so the warning is said once per distinct set (ADR-0061). */
34
34
  private _reportedModelProblems;
35
+ /**
36
+ * What Hello does not send again (1.5.21, TR-0069; twin of .NET `G9CSyncEngine.HelloCache`): the last negotiated tables and definitions,
37
+ * with the server's ids for them. In memory only: the first Hello of a process gets them in full.
38
+ */
39
+ private _lastAnswer;
40
+ /** The model as Hello sends it, built once: the transport recognises an unchanged model by this very array. */
41
+ private _wireTables;
35
42
  constructor(store: G9SyncClientStore, transport: G9SyncTransport, model: G9SyncClientModel, options?: G9SyncEngineOptions);
36
43
  get clientId(): string;
37
44
  get isRunning(): boolean;
@@ -91,6 +98,21 @@ export declare class G9SyncEngine {
91
98
  getChangeFeedFloor(signal?: AbortSignal): Promise<number>;
92
99
  adoptExistingRows(signal?: AbortSignal): Promise<number>;
93
100
  markDirty(localTable: string, keys: readonly string[], signal?: AbortSignal): Promise<number>;
101
+ /**
102
+ * `RefetchRowsAsync` (1.5.19): has the next sync fetch these rows again and overwrite their local values with the server's
103
+ * (all of a table, or `keys`) - for a server whose READ of a column changed while the rows did not, e.g. a column it now
104
+ * serves empty (a reconcile compares key + version, never values). Only clean rows a subscription holds; a row with a pending
105
+ * change, a rejection or a delete is left alone. Nothing is uploaded. Their subscriptions become NeedsReconcile, so the next
106
+ * sync of any kind does it, offline in between or not. Returns the rows marked.
107
+ */
108
+ refetchRows(localTable: string, keys?: readonly string[] | null, signal?: AbortSignal): Promise<number>;
109
+ /**
110
+ * `ReleaseUnclaimedRowsAsync` (1.5.19): removes rows of one table (all, or `keys`) that no subscription holds and the server
111
+ * never claimed (`server_ver = 0`: adopted from the device's own table, never confirmed since). Locally only: capture is off,
112
+ * nothing is uploaded. A row with a pending (or refused) change stays unless `includePending` - then the revocation policy
113
+ * decides (by default quarantined, the refusal with it). Raises RowsApplied (PostStep) when anything left.
114
+ */
115
+ releaseUnclaimedRows(localTable: string, keys?: readonly string[] | null, includePending?: boolean, signal?: AbortSignal): Promise<G9ApplyReport>;
94
116
  getHealth(signal?: AbortSignal): Promise<G9SyncHealth>;
95
117
  dispose(): Promise<void>;
96
118
  private _signal;
@@ -42,6 +42,13 @@ export class G9SyncEngine {
42
42
  this._disposed = false;
43
43
  /** The model problems last logged, so the warning is said once per distinct set (ADR-0061). */
44
44
  this._reportedModelProblems = null;
45
+ /**
46
+ * What Hello does not send again (1.5.21, TR-0069; twin of .NET `G9CSyncEngine.HelloCache`): the last negotiated tables and definitions,
47
+ * with the server's ids for them. In memory only: the first Hello of a process gets them in full.
48
+ */
49
+ this._lastAnswer = null;
50
+ /** The model as Hello sends it, built once: the transport recognises an unchanged model by this very array. */
51
+ this._wireTables = null;
45
52
  if (!store)
46
53
  throw new TypeError('A store is required.');
47
54
  if (!transport)
@@ -276,6 +283,34 @@ export class G9SyncEngine {
276
283
  await this.initialize(signal);
277
284
  return this._store.markDirty(localTable, keys, signal);
278
285
  }
286
+ /**
287
+ * `RefetchRowsAsync` (1.5.19): has the next sync fetch these rows again and overwrite their local values with the server's
288
+ * (all of a table, or `keys`) - for a server whose READ of a column changed while the rows did not, e.g. a column it now
289
+ * serves empty (a reconcile compares key + version, never values). Only clean rows a subscription holds; a row with a pending
290
+ * change, a rejection or a delete is left alone. Nothing is uploaded. Their subscriptions become NeedsReconcile, so the next
291
+ * sync of any kind does it, offline in between or not. Returns the rows marked.
292
+ */
293
+ async refetchRows(localTable, keys = null, signal) {
294
+ if (!localTable || !localTable.trim())
295
+ throw new Error('localTable is required');
296
+ await this.initialize(signal);
297
+ return this._store.refetchRows(localTable, keys, signal);
298
+ }
299
+ /**
300
+ * `ReleaseUnclaimedRowsAsync` (1.5.19): removes rows of one table (all, or `keys`) that no subscription holds and the server
301
+ * never claimed (`server_ver = 0`: adopted from the device's own table, never confirmed since). Locally only: capture is off,
302
+ * nothing is uploaded. A row with a pending (or refused) change stays unless `includePending` - then the revocation policy
303
+ * decides (by default quarantined, the refusal with it). Raises RowsApplied (PostStep) when anything left.
304
+ */
305
+ async releaseUnclaimedRows(localTable, keys = null, includePending = false, signal) {
306
+ if (!localTable || !localTable.trim())
307
+ throw new Error('localTable is required');
308
+ await this.initialize(signal);
309
+ const report = await this._store.releaseUnclaimedRows(localTable, keys, includePending, signal);
310
+ if (report.revokedDeleted + report.revokedQuarantined + report.revokedRetained > 0)
311
+ this._raiseRowsApplied(report, G9ESyncPhase.PostStep);
312
+ return report;
313
+ }
279
314
  async getHealth(signal) {
280
315
  await this.initialize(signal);
281
316
  const state = await this._store.getServerState(signal);
@@ -431,6 +466,7 @@ export class G9SyncEngine {
431
466
  }
432
467
  // ================================================================ execution
433
468
  async _executeRun(run) {
469
+ var _a, _b, _c;
434
470
  const request = run.request;
435
471
  const startedUtcMs = Date.now();
436
472
  const changes = new G9ApplyReport();
@@ -465,10 +501,19 @@ export class G9SyncEngine {
465
501
  hostScope = await this._options.hooks.beginRun(run, ct);
466
502
  // ---------------------------------------------------------------- 1. connect + hello
467
503
  tracker.enter(G9ESyncPhase.Connecting);
504
+ // Where the Connecting phase goes (AgriPad TR-0069, 2026-11-13: «Connecting takes long»): each step is timed and the
505
+ // split logged once per run (event 20030) — the transport, the ephemeral sweep, reading the subscriptions, the
506
+ // Hello round trip and the save of its answer are separate costs with separate cures.
507
+ const connectT0 = Date.now();
468
508
  await this._ensureConnected(ct);
509
+ const connectT1 = Date.now();
469
510
  await this._store.collectExpiredEphemeral(Date.now(), ct);
511
+ const connectT2 = Date.now();
470
512
  const subscriptions = await this._store.getSubscriptions(ct);
513
+ const connectT3 = Date.now();
471
514
  let helloMidpointMs = Date.now();
515
+ const known = this._lastAnswer;
516
+ const wireTables = (_a = this._wireTables) !== null && _a !== void 0 ? _a : (this._wireTables = this.model.tables.map((t) => t.toWire()));
472
517
  const hello = await this._withRetry(async (token) => {
473
518
  // The round trip of the attempt that answered: its midpoint is the local time of the server's clock reading (LES-0075).
474
519
  const sent = Date.now();
@@ -476,15 +521,34 @@ export class G9SyncEngine {
476
521
  clientId: this._clientId,
477
522
  appVersion: this._options.appVersion,
478
523
  protocolVersion: 1,
479
- tables: this.model.tables.map((t) => t.toWire()),
524
+ tables: wireTables,
480
525
  subscriptions: subscriptions.map(toSubscriptionRef),
526
+ knownTablesHash: known ? known.tablesHash : null,
527
+ knownDefinitionsHash: known ? known.definitionsHash : null,
481
528
  }, token);
482
529
  helloMidpointMs = sent + (Date.now() - sent) / 2;
483
530
  return answer;
484
531
  }, ct);
485
532
  if (hello.error)
486
533
  throw syncErrorToException(hello.error);
534
+ const unchanged = (hello.tablesUnchanged ? 'tables' : '') + (hello.definitionsUnchanged ? (hello.tablesUnchanged ? '+' : '') + 'definitions' : '');
535
+ restoreUnchanged(hello, known);
536
+ const connectT4 = Date.now();
487
537
  await this._store.saveHello(hello, ct, helloMidpointMs);
538
+ this._lastAnswer =
539
+ hello.tablesHash || hello.definitionsHash
540
+ ? {
541
+ tablesHash: (_b = hello.tablesHash) !== null && _b !== void 0 ? _b : null,
542
+ tables: hello.tables,
543
+ unknownTables: hello.unknownTables,
544
+ definitionsHash: (_c = hello.definitionsHash) !== null && _c !== void 0 ? _c : null,
545
+ definitions: hello.definitions,
546
+ }
547
+ : null;
548
+ const connectT5 = Date.now();
549
+ this._log('information', 20030, `Connecting ${connectT5 - connectT0} ms: transport ${connectT1 - connectT0}, ephemeral sweep ${connectT2 - connectT1}, ` +
550
+ `subscriptions ${connectT3 - connectT2} (${subscriptions.length}), hello ${connectT4 - connectT3} ` +
551
+ `(${this.model.tables.length} tables; unchanged: ${unchanged || 'none'}), save ${connectT5 - connectT4}`);
488
552
  if (hello.unknownTables.length > 0) {
489
553
  this._log('warning', 20016, 'The server does not know these client tables (they will not sync): ' + hello.unknownTables.join(', '));
490
554
  }
@@ -1230,4 +1294,21 @@ async function* toChunks(batch) {
1230
1294
  for (const frame of batch.frames)
1231
1295
  yield { sequence: sequence++, payload: frame, rowCount: 0, isFinal: false };
1232
1296
  }
1297
+ /**
1298
+ * Puts back what an "unchanged" Hello answer left out (1.5.21; twin of .NET `RestoreUnchanged`), so the rest of a run reads a complete
1299
+ * answer either way. An answer that says unchanged for something this client never held is a server fault.
1300
+ */
1301
+ function restoreUnchanged(hello, known) {
1302
+ if (hello.tablesUnchanged) {
1303
+ if (!known)
1304
+ throw new G9SyncException(G9SyncErrorCodes.ProtocolMismatch, "The server answered 'tables unchanged' to a client that holds none.");
1305
+ hello.tables = known.tables;
1306
+ hello.unknownTables = known.unknownTables;
1307
+ }
1308
+ if (hello.definitionsUnchanged) {
1309
+ if (!known)
1310
+ throw new G9SyncException(G9SyncErrorCodes.ProtocolMismatch, "The server answered 'definitions unchanged' to a client that holds none.");
1311
+ hello.definitions = known.definitions;
1312
+ }
1313
+ }
1233
1314
  //# sourceMappingURL=engine.js.map