dynamodb-reactive 0.4.0 → 0.5.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/README.md CHANGED
@@ -24,17 +24,17 @@ pnpm add aws-cdk-lib constructs esbuild
24
24
 
25
25
  Every public entry point supplies ESM (`.js` and `.d.ts`) and CommonJS (`.cjs` and `.d.cts`) conditions, so both `import` and `require()` consumers are supported:
26
26
 
27
- | Import | Contents |
28
- | --- | --- |
29
- | `dynamodb-reactive` | Core table and schema exports |
30
- | `dynamodb-reactive/core` | Same core API, explicitly named |
31
- | `dynamodb-reactive/server` | Routers, database context, and HTTP/stream handlers |
32
- | `dynamodb-reactive/client` | React-free client, WebSocket manager, and query-key helpers |
33
- | `dynamodb-reactive/react` | Provider and React hooks |
34
- | `dynamodb-reactive/infra` | `ReactiveEngine` and its configuration types |
35
- | `dynamodb-reactive/cluster` | Cluster-worker connection, signed tickets, and wire-protocol types |
36
- | `dynamodb-reactive/hub` | Runtime-free reactive hub core for backends that hold WebSockets themselves |
37
- | `dynamodb-reactive/cloudflare` | Durable Object hub shell and ticket, socket, and reactive HTTP routes |
27
+ | Import | Contents |
28
+ | ------------------------------ | --------------------------------------------------------------------------- |
29
+ | `dynamodb-reactive` | Core table and schema exports |
30
+ | `dynamodb-reactive/core` | Same core API, explicitly named |
31
+ | `dynamodb-reactive/server` | Routers, database context, and HTTP/stream handlers |
32
+ | `dynamodb-reactive/client` | React-free client, WebSocket manager, and query-key helpers |
33
+ | `dynamodb-reactive/react` | Provider and React hooks |
34
+ | `dynamodb-reactive/infra` | `ReactiveEngine` and its configuration types |
35
+ | `dynamodb-reactive/cluster` | Cluster-worker connection, signed tickets, and wire-protocol types |
36
+ | `dynamodb-reactive/hub` | Runtime-free reactive hub core for backends that hold WebSockets themselves |
37
+ | `dynamodb-reactive/cloudflare` | Durable Object hub shell and ticket, socket, and reactive HTTP routes |
38
38
 
39
39
  React code must import the provider and hooks from `dynamodb-reactive/react`. Importing `dynamodb-reactive/client` does not load React.
40
40
 
@@ -86,20 +86,18 @@ const t = initReactive<AppContext>();
86
86
 
87
87
  export const appRouter = t.router({
88
88
  todos: {
89
- list: t.procedure
90
- .input(z.object({ ownerId: z.string() }))
91
- .query(({ ctx, input }) => {
92
- if (input.ownerId !== ctx.principalId) {
93
- throw new Error('Forbidden');
94
- }
95
- return ctx.db
96
- .query(TodoTable)
97
- .useIndex('byOwner')
98
- .filter((q) => q.eq(TodoTable.field.ownerId, input.ownerId))
99
- .sortDescending()
100
- .take(100)
101
- .execute();
102
- }),
89
+ list: t.procedure.input(z.object({ ownerId: z.string() })).query(({ ctx, input }) => {
90
+ if (input.ownerId !== ctx.principalId) {
91
+ throw new Error('Forbidden');
92
+ }
93
+ return ctx.db
94
+ .query(TodoTable)
95
+ .useIndex('byOwner')
96
+ .filter((q) => q.eq(TodoTable.field.ownerId, input.ownerId))
97
+ .sortDescending()
98
+ .take(100)
99
+ .execute();
100
+ }),
103
101
 
104
102
  create: t.procedure
105
103
  .input(z.object({ text: z.string().min(1) }))
@@ -128,15 +126,13 @@ export const appRouter = t.router({
128
126
  ),
129
127
  ),
130
128
 
131
- delete: t.procedure
132
- .input(z.object({ id: z.string() }))
133
- .mutation(async ({ ctx, input }) => {
134
- await ctx.db.delete(TodoTable, {
135
- pk: `USER#${ctx.principalId}`,
136
- sk: `TODO#${input.id}`,
137
- });
138
- return { id: input.id };
139
- }),
129
+ delete: t.procedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
130
+ await ctx.db.delete(TodoTable, {
131
+ pk: `USER#${ctx.principalId}`,
132
+ sk: `TODO#${input.id}`,
133
+ });
134
+ return { id: input.id };
135
+ }),
140
136
  },
141
137
  });
142
138
 
@@ -299,10 +295,7 @@ The HTTP principal must own the WebSocket connection named in the request. The c
299
295
 
300
296
  ```ts
301
297
  // reactive-api.ts
302
- import {
303
- createReactiveHandler,
304
- type ReactiveRequest,
305
- } from 'dynamodb-reactive/server';
298
+ import { createReactiveHandler, type ReactiveRequest } from 'dynamodb-reactive/server';
306
299
  import { z } from 'zod';
307
300
 
308
301
  import { authenticateBearer } from './auth.js';
@@ -430,9 +423,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
430
423
  return (
431
424
  <section aria-busy={todos.loading || todos.stale}>
432
425
  <p>Connection: {connection}</p>
433
- <button onClick={() => void createTodo.mutate({ text: 'New todo' })}>
434
- Add
435
- </button>
426
+ <button onClick={() => void createTodo.mutate({ text: 'New todo' })}>Add</button>
436
427
  {todos.data?.map((todo) => (
437
428
  <div key={todo.id}>
438
429
  <button
@@ -446,9 +437,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
446
437
  {todo.completed ? 'Undo' : 'Complete'}
447
438
  </button>
448
439
  <span>{todo.text}</span>
449
- <button onClick={() => void deleteTodo.mutate({ id: todo.id })}>
450
- Delete
451
- </button>
440
+ <button onClick={() => void deleteTodo.mutate({ id: todo.id })}>Delete</button>
452
441
  </div>
453
442
  ))}
454
443
  </section>
@@ -567,14 +556,26 @@ const paths = (path: string) =>
567
556
 
568
557
  ```ts
569
558
  import { DurableObject } from 'cloudflare:workers';
570
- import { createDurableObjectHub, handleReactiveRoutes, hubClient } from 'dynamodb-reactive/cloudflare';
559
+ import {
560
+ createDurableObjectHub,
561
+ handleReactiveRoutes,
562
+ hubClient,
563
+ } from 'dynamodb-reactive/cloudflare';
571
564
 
572
565
  export class ReactiveHubObject extends DurableObject<Env> {
573
566
  private readonly hub = createDurableObjectHub(this.ctx, { paths });
574
- override fetch(request: Request) { return this.hub.fetch(request); }
575
- override webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) { this.hub.webSocketMessage(socket, message); }
576
- override webSocketClose(socket: WebSocket, code: number) { this.hub.webSocketClose(socket, code); }
577
- override webSocketError(socket: WebSocket) { this.hub.webSocketError(socket); }
567
+ override fetch(request: Request) {
568
+ return this.hub.fetch(request);
569
+ }
570
+ override webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) {
571
+ this.hub.webSocketMessage(socket, message);
572
+ }
573
+ override webSocketClose(socket: WebSocket, code: number) {
574
+ return this.hub.webSocketClose(socket, code);
575
+ }
576
+ override webSocketError(socket: WebSocket) {
577
+ return this.hub.webSocketError(socket);
578
+ }
578
579
  }
579
580
 
580
581
  const hub = (env: Env) => env.HUB.get(env.HUB.idFromName('hub'));
@@ -582,7 +583,11 @@ const hub = (env: Env) => env.HUB.get(env.HUB.idFromName('hub'));
582
583
  export default {
583
584
  async fetch(request: Request, env: Env) {
584
585
  const response = await handleReactiveRoutes(request, {
585
- routes: { ticket: '/api/reactive/token', socket: '/api/reactive/socket', reactive: '/api/reactive' },
586
+ routes: {
587
+ ticket: '/api/reactive/token',
588
+ socket: '/api/reactive/socket',
589
+ reactive: '/api/reactive',
590
+ },
586
591
  secret: env.REACTIVE_TICKET_SECRET,
587
592
  hub: () => hub(env),
588
593
  authenticate: (request) => principalFor(request), // undefined answers 401
@@ -597,6 +602,26 @@ await hubClient(hub(env)).notify('drafts.123');
597
602
 
598
603
  `handleReactiveRoutes` issues signed tickets, verifies them on the WebSocket upgrade and on reactive HTTP requests, and forwards both to the hub with the verified principal. It answers 503 while `secret` is unset. By default the object answers `ping` with `pong` through the runtime's WebSocket auto-response, so heartbeats do not wake a hibernating object. Versions are kept in the object's storage under `version:<path>`; pass `versionKey` to change that. To shard, pick the instance per path with `idFromName`.
599
604
 
605
+ ### Presence
606
+
607
+ Pass `presence` to know whether a principal, such as a cluster worker, is online without it polling over HTTP:
608
+
609
+ ```ts
610
+ private readonly hub = createDurableObjectHub(this.ctx, {
611
+ paths,
612
+ presence: {
613
+ principals: ['worker'],
614
+ timeoutMs: 90_000, // the default; clients must ping more often than this
615
+ onChange: () => hubClient(stub).notify('cluster.status'),
616
+ },
617
+ });
618
+ override alarm() {
619
+ return this.hub.alarm();
620
+ }
621
+ ```
622
+
623
+ `hub.present(principal)` is true while the principal holds a socket that pinged within `timeoutMs`. The runtime records each auto-answered ping without waking the object, so presence costs nothing per heartbeat. An alarm wakes the object when the stalest tracked socket would time out, closes silent sockets with code `4001`, and reschedules while tracked sockets remain. That sweep is what catches a peer that vanished without a close frame, which an open socket alone never reveals. `onChange` runs when a principal gains its first live socket or loses its last one; the last reported state is stored under `presence:<principal>`, so each change is reported once. Presence needs `autoPong` and the object's `alarm` delegated to the hub.
624
+
600
625
  ## Delivery model
601
626
 
602
627
  1. The client establishes a WebSocket and receives its API Gateway connection ID.
@@ -606,7 +631,7 @@ await hubClient(hub(env)).notify('drafts.123');
606
631
 
607
632
  Pending delivery is durable until a send completes, so retries are at least once. A send followed by a failed clear can produce a duplicate; clients must keep duplicate handling enabled. Patch messages larger than 30 KiB become a small `resync` instruction rather than a WebSocket payload. Serialized subscription state is limited to 300 KiB.
608
633
 
609
- Connection and subscription records receive three-hour TTLs. Each authorized HTTP operation refreshes the connection lease; registration and re-registration renew the query and dependency records. The browser sends a WebSocket heartbeat every five minutes, but API Gateway can still close idle or maximum-age connections; the client reconnects and re-registers active subscriptions. DynamoDB TTL removes expired records asynchronously and is cleanup, not an authorization or immediate-deletion mechanism. HTTP ownership checks explicitly enforce connection status and lease expiry.
634
+ Connection and subscription records receive three-hour TTLs. Each authorized HTTP operation refreshes the connection lease; registration and re-registration renew the query and dependency records. The browser sends a WebSocket heartbeat every five minutes (`heartbeatInterval`), but API Gateway can still close idle or maximum-age connections; the client reconnects and re-registers active subscriptions. A heartbeat unanswered within `heartbeatTimeout` (15 seconds by default) marks the socket dead, so a server or network that vanished without a close frame is replaced instead of trusted. `connectClusterWorker` pings every 30 seconds. DynamoDB TTL removes expired records asynchronously and is cleanup, not an authorization or immediate-deletion mechanism. HTTP ownership checks explicitly enforce connection status and lease expiry.
610
635
 
611
636
  ## Security
612
637
 
@@ -45,6 +45,17 @@ interface ReactiveClientConfig {
45
45
  connectionTimeout?: number;
46
46
  /** Maximum time for the init/connected/pong handshake. @default 10000 */
47
47
  handshakeTimeout?: number;
48
+ /**
49
+ * Interval between `ping` frames once connected. The default suits API Gateway, which bills
50
+ * every message and closes sockets idle for 10 minutes; Durable Object hubs answer pings
51
+ * without waking, so cluster workers can ping far more often. @default 300000
52
+ */
53
+ heartbeatInterval?: number;
54
+ /**
55
+ * Time a `ping` may go unanswered before the socket is treated as dead and replaced. A server
56
+ * that vanishes without closing the connection is otherwise never noticed. @default 15000
57
+ */
58
+ heartbeatTimeout?: number;
48
59
  /**
49
60
  * Callback when connected
50
61
  */
package/dist/client.cjs CHANGED
@@ -29,9 +29,7 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
29
29
  if (Array.isArray(value)) {
30
30
  const items = [];
31
31
  for (let index = 0; index < value.length; index++) {
32
- items.push(
33
- canonicalize(value[index], seen, String(index), true) ?? "null"
34
- );
32
+ items.push(canonicalize(value[index], seen, String(index), true) ?? "null");
35
33
  }
36
34
  return `[${items.join(",")}]`;
37
35
  }
@@ -48,6 +46,9 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
48
46
  seen.delete(value);
49
47
  }
50
48
  }
49
+ case "bigint":
50
+ case "function":
51
+ case "symbol":
51
52
  default:
52
53
  throw new TypeError(`Unsupported query input value: ${typeof value}`);
53
54
  }
@@ -55,7 +56,6 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
55
56
  function getQueryKey(path, input) {
56
57
  return `${JSON.stringify(path)}:${canonicalize(input, /* @__PURE__ */ new Set(), "") ?? "undefined"}`;
57
58
  }
58
- var { applyPatch } = jsonpatch__default.default;
59
59
  function applyPatches(document, patches) {
60
60
  if (patches.length === 0) {
61
61
  return document;
@@ -74,7 +74,7 @@ function applyPatches(document, patches) {
74
74
  return op;
75
75
  });
76
76
  try {
77
- const result = applyPatch(
77
+ const result = jsonpatch__default.default.applyPatch(
78
78
  structuredClone(document),
79
79
  operations,
80
80
  true,
@@ -94,12 +94,13 @@ function error(...values) {
94
94
  }
95
95
 
96
96
  // ../client/src/websocket.ts
97
- var HEARTBEAT_INTERVAL_MS = 5 * 60 * 1e3;
98
97
  function withDefaults(config) {
99
98
  return {
100
99
  autoReconnect: true,
101
100
  connectionTimeout: 1e4,
102
101
  handshakeTimeout: 1e4,
102
+ heartbeatInterval: 5 * 60 * 1e3,
103
+ heartbeatTimeout: 15e3,
103
104
  maxReconnectAttempts: 10,
104
105
  reconnectDelay: 1e3,
105
106
  ...config
@@ -113,6 +114,7 @@ var WebSocketManager = class {
113
114
  reconnectTimer = null;
114
115
  handshakeTimer = null;
115
116
  heartbeatTimer = null;
117
+ pongTimer = null;
116
118
  messageHandlers = /* @__PURE__ */ new Set();
117
119
  stateHandlers = /* @__PURE__ */ new Set();
118
120
  handshakeHandlers = /* @__PURE__ */ new Set();
@@ -212,16 +214,7 @@ var WebSocketManager = class {
212
214
  };
213
215
  nextSocket.onclose = () => {
214
216
  if (!this.isCurrent(nextSocket, generation)) return;
215
- this.clearConnectionTimers();
216
- this.ws = null;
217
- this._connectionId = null;
218
- this.setState("disconnected");
219
- this.invokeLifecycleCallback("onDisconnect", this.config.onDisconnect);
220
- if (this.config.autoReconnect && !this.intentionalDisconnect) {
221
- this.scheduleReconnect();
222
- } else {
223
- this.rejectWaiters(new Error("WebSocket disconnected"));
224
- }
217
+ this.handleClose();
225
218
  };
226
219
  nextSocket.onerror = () => {
227
220
  if (!this.isCurrent(nextSocket, generation)) return;
@@ -310,6 +303,7 @@ var WebSocketManager = class {
310
303
  return;
311
304
  }
312
305
  if (message.type === "pong") {
306
+ this.clearPongTimer();
313
307
  if (!this._connectionId || this.state === "connected") return;
314
308
  this.clearHandshakeTimer();
315
309
  this.lastConnectionError = void 0;
@@ -338,6 +332,18 @@ var WebSocketManager = class {
338
332
  socket.close(4e3, "Handshake timeout");
339
333
  }, this.config.handshakeTimeout);
340
334
  }
335
+ handleClose() {
336
+ this.clearConnectionTimers();
337
+ this.ws = null;
338
+ this._connectionId = null;
339
+ this.setState("disconnected");
340
+ this.invokeLifecycleCallback("onDisconnect", this.config.onDisconnect);
341
+ if (this.config.autoReconnect && !this.intentionalDisconnect) {
342
+ this.scheduleReconnect();
343
+ } else {
344
+ this.rejectWaiters(new Error("WebSocket disconnected"));
345
+ }
346
+ }
341
347
  startHeartbeat(socket, generation) {
342
348
  this.clearHeartbeatTimer();
343
349
  this.heartbeatTimer = setInterval(() => {
@@ -345,7 +351,17 @@ var WebSocketManager = class {
345
351
  return;
346
352
  }
347
353
  socket.send(JSON.stringify({ type: "ping" }));
348
- }, HEARTBEAT_INTERVAL_MS);
354
+ this.pongTimer ??= setTimeout(() => {
355
+ this.pongTimer = null;
356
+ if (!this.isCurrent(socket, generation)) return;
357
+ const failure = new Error("WebSocket heartbeat timed out");
358
+ this.lastConnectionError = failure;
359
+ this.invokeLifecycleCallback("onError", this.config.onError, failure);
360
+ socket.onclose = null;
361
+ socket.close(4001, "Heartbeat timeout");
362
+ this.handleClose();
363
+ }, this.config.heartbeatTimeout);
364
+ }, this.config.heartbeatInterval);
349
365
  }
350
366
  isCurrent(socket, generation) {
351
367
  return this.ws === socket && this.socketGeneration === generation;
@@ -412,9 +428,15 @@ var WebSocketManager = class {
412
428
  clearInterval(this.heartbeatTimer);
413
429
  this.heartbeatTimer = null;
414
430
  }
431
+ clearPongTimer() {
432
+ if (!this.pongTimer) return;
433
+ clearTimeout(this.pongTimer);
434
+ this.pongTimer = null;
435
+ }
415
436
  clearConnectionTimers() {
416
437
  this.clearHandshakeTimer();
417
438
  this.clearHeartbeatTimer();
439
+ this.clearPongTimer();
418
440
  }
419
441
  clearTimers() {
420
442
  this.clearConnectionTimers();
@@ -514,10 +536,7 @@ var ReactiveClient = class {
514
536
  this.subscriptionsByKey.set(key, shared);
515
537
  this.subscriptionsById.set(shared.id, shared);
516
538
  if (this.connectionState === "connected") {
517
- void this.ensureRegistered(
518
- shared,
519
- this.wsManager.generation
520
- ).catch(() => void 0);
539
+ void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
521
540
  }
522
541
  }
523
542
  const leaseId = generateId();
@@ -526,9 +545,7 @@ var ReactiveClient = class {
526
545
  onError: options.onError
527
546
  });
528
547
  if (this.connectionState === "connected" && (!shared.hasRegistered || shared.stale) && !shared.registrationPromise) {
529
- void this.ensureRegistered(shared, this.wsManager.generation).catch(
530
- () => void 0
531
- );
548
+ void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
532
549
  }
533
550
  const state = shared;
534
551
  let released = false;
@@ -617,9 +634,7 @@ var ReactiveClient = class {
617
634
  registerActiveSubscriptions(socketGeneration) {
618
635
  for (const state of this.subscriptionsByKey.values()) {
619
636
  if (state.active) {
620
- void this.ensureRegistered(state, socketGeneration).catch(
621
- () => void 0
622
- );
637
+ void this.ensureRegistered(state, socketGeneration).catch(() => void 0);
623
638
  }
624
639
  }
625
640
  }
@@ -672,9 +687,7 @@ var ReactiveClient = class {
672
687
  })
673
688
  });
674
689
  if (!response.ok) {
675
- throw new Error(
676
- `Subscribe failed: ${response.status} ${response.statusText}`
677
- );
690
+ throw new Error(`Subscribe failed: ${response.status} ${response.statusText}`);
678
691
  }
679
692
  const result = await response.json();
680
693
  if (result.type === "error") {
@@ -693,12 +706,7 @@ var ReactiveClient = class {
693
706
  throw cause;
694
707
  } finally {
695
708
  if ((!state.active || !isCurrent()) && requestIssued) {
696
- void this.sendUnsubscribe(
697
- registrationConnectionId,
698
- state.id,
699
- generation,
700
- registrationId
701
- );
709
+ void this.sendUnsubscribe(registrationConnectionId, state.id, generation, registrationId);
702
710
  }
703
711
  if (state.registrationPromise === request) {
704
712
  state.registrationPromise = void 0;
@@ -803,15 +811,12 @@ var ReactiveClient = class {
803
811
  return;
804
812
  }
805
813
  this.discardStalePatches(state);
806
- while (true) {
807
- const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion)[0];
814
+ for (; ; ) {
815
+ const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion).at(0);
808
816
  if (!next) break;
809
817
  state.patchBuffer.delete(next.messageId);
810
818
  try {
811
- state.authoritativeData = applyPatches(
812
- state.authoritativeData,
813
- next.patches
814
- );
819
+ state.authoritativeData = applyPatches(state.authoritativeData, next.patches);
815
820
  state.version = next.toVersion;
816
821
  state.appliedMessageIds.add(next.messageId);
817
822
  const awaitingReconciliation = this.hasSettledLayers(state);
@@ -849,12 +854,7 @@ var ReactiveClient = class {
849
854
  state.stale = true;
850
855
  state.loading = true;
851
856
  this.notifySubscription(state);
852
- let request;
853
- request = this.ensureRegistered(
854
- state,
855
- this.wsManager.generation,
856
- true
857
- ).finally(() => {
857
+ const request = this.ensureRegistered(state, this.wsManager.generation, true).finally(() => {
858
858
  if (state.resyncPromise === request) {
859
859
  state.resyncPromise = void 0;
860
860
  state.resyncGeneration = void 0;
@@ -922,7 +922,10 @@ var ReactiveClient = class {
922
922
  if (options?.optimisticData === void 0) return void 0;
923
923
  return (data) => {
924
924
  if (!Array.isArray(data)) return data;
925
- const optimistic = settled ? result === void 0 ? typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData : result : typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData;
925
+ const optimistic = settled ? (
926
+ // oxlint-disable-next-line typescript/prefer-nullish-coalescing -- null is a valid mutation result.
927
+ result === void 0 ? typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData : result
928
+ ) : typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData;
926
929
  if (settled && optimistic !== null && typeof optimistic === "object") {
927
930
  const optimisticId = optimistic.id;
928
931
  if (optimisticId !== void 0 && data.some(
@@ -937,12 +940,7 @@ var ReactiveClient = class {
937
940
  settleMutationLayers(mutationId, input, result, options, states) {
938
941
  for (const state of states) {
939
942
  const layer = state.optimisticLayers.get(mutationId);
940
- const updater = this.createOptimisticUpdater(
941
- input,
942
- options,
943
- result,
944
- true
945
- );
943
+ const updater = this.createOptimisticUpdater(input, options, result, true);
946
944
  if (!layer || !updater) continue;
947
945
  layer.updater = updater;
948
946
  layer.settledRevision = ++state.settlementRevision;