@delali/sirannon-db 0.1.7 → 0.2.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.
Files changed (72) hide show
  1. package/README.md +117 -969
  2. package/dist/backup-scheduler/index.d.ts +1 -1
  3. package/dist/backup-scheduler/index.mjs +2 -2
  4. package/dist/baseline-Br77Fnhb.d.ts +6 -0
  5. package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
  6. package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
  7. package/dist/chunk-5NOIGN5Y.mjs +18 -0
  8. package/dist/chunk-67M7KAH6.mjs +90 -0
  9. package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
  10. package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
  11. package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
  12. package/dist/chunk-GVCNMPOS.mjs +130 -0
  13. package/dist/chunk-H237TXZW.mjs +1407 -0
  14. package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
  15. package/dist/chunk-HR5CWTLC.mjs +661 -0
  16. package/dist/chunk-JU64Y7HM.mjs +299 -0
  17. package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
  18. package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
  19. package/dist/chunk-LNY2VVHE.mjs +18 -0
  20. package/dist/chunk-NVQS53NT.mjs +665 -0
  21. package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
  22. package/dist/chunk-OQVZBEBY.mjs +283 -0
  23. package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
  24. package/dist/chunk-TJF5GZSV.mjs +187 -0
  25. package/dist/chunk-TUD5CJ76.mjs +126 -0
  26. package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
  27. package/dist/client/index.d.ts +216 -321
  28. package/dist/client/index.mjs +1192 -1342
  29. package/dist/client/topology.d.ts +61 -0
  30. package/dist/client/topology.mjs +682 -0
  31. package/dist/client-base-CLWmH5Ln.d.ts +234 -0
  32. package/dist/codegen/cli.d.ts +2 -0
  33. package/dist/codegen/cli.mjs +11 -0
  34. package/dist/codegen/index.d.ts +30 -0
  35. package/dist/codegen/index.mjs +5 -0
  36. package/dist/core/index.d.ts +37 -16
  37. package/dist/core/index.mjs +2322 -298
  38. package/dist/core/writer-worker.mjs +28 -6
  39. package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
  40. package/dist/driver/better-sqlite3.d.ts +1 -1
  41. package/dist/driver/better-sqlite3.mjs +6 -6
  42. package/dist/driver/node.d.ts +1 -1
  43. package/dist/driver/node.mjs +6 -6
  44. package/dist/driver/wa-sqlite.d.ts +1 -136
  45. package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
  46. package/dist/file-migrations/index.d.ts +7 -5
  47. package/dist/file-migrations/index.mjs +42 -37
  48. package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
  49. package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
  50. package/dist/protocol-6KrSq2Hy.d.ts +66 -0
  51. package/dist/react/index.d.ts +20 -0
  52. package/dist/react/index.mjs +137 -0
  53. package/dist/replication/coordinator/etcd.d.ts +5 -3
  54. package/dist/replication/coordinator/etcd.mjs +554 -462
  55. package/dist/replication/index.d.ts +51 -105
  56. package/dist/replication/index.mjs +1415 -2061
  57. package/dist/server/index.d.ts +111 -87
  58. package/dist/server/index.mjs +1917 -489
  59. package/dist/server-options-1JHu8pid.d.ts +191 -0
  60. package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
  61. package/dist/transport/grpc.d.ts +7 -4
  62. package/dist/transport/grpc.mjs +191 -97
  63. package/dist/transport/memory.d.ts +5 -134
  64. package/dist/transport/memory.mjs +3 -1
  65. package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
  66. package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
  67. package/dist/types-CL6piSnD.d.ts +43 -0
  68. package/dist/types-C_D8IhpO.d.ts +60 -0
  69. package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
  70. package/package.json +44 -6
  71. package/dist/chunk-TKGHYWQ6.mjs +0 -35
  72. package/dist/types-D4p4UyDK.d.ts +0 -26
@@ -1,183 +1,312 @@
1
1
  import { compatibilityAllowsPromotion } from '../../chunk-ER7ODTDA.mjs';
2
- import { CoordinatorError, NoSafePrimaryError } from '../../chunk-H5AB6NIR.mjs';
3
- import '../../chunk-YPYVQJ4C.mjs';
2
+ import { CoordinatorError, NoSafePrimaryError } from '../../chunk-O7SLN3GI.mjs';
3
+ import '../../chunk-5NOIGN5Y.mjs';
4
+ import '../../chunk-UC3SCMIN.mjs';
4
5
  import { Etcd3 } from 'etcd3';
5
6
 
7
+ // src/replication/coordinator/group-rules.ts
6
8
  var MIN_AUTOMATIC_FAILOVER_VOTERS = 3;
7
- var EtcdClusterCoordinator = class {
8
- client;
9
- namespace;
10
- onWatcherError;
11
- leases = /* @__PURE__ */ new Map();
12
- watchers = /* @__PURE__ */ new Set();
13
- constructor(options) {
14
- assertEtcdOptions(options);
15
- this.client = new Etcd3(toEtcdOptions(options));
16
- this.namespace = this.client.namespace(normaliseKeyPrefix(options.keyPrefix));
17
- this.onWatcherError = options.onWatcherError;
9
+ function assertNonEmpty(value, name) {
10
+ if (value.length === 0) {
11
+ throw new TypeError(`${name} must not be empty`);
18
12
  }
19
- async tryAcquireControllerLease(input) {
20
- assertNonEmpty(input.clusterId, "clusterId");
21
- assertNonEmpty(input.holderId, "holderId");
22
- assertPositiveTtl(input.ttlMs);
23
- const key = controllerLeaseKey(input.clusterId);
24
- const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
25
- const leaseId = await lease.grant();
26
- const grantedAtMs = Date.now();
27
- const value = serializeLease({
28
- id: leaseId,
29
- kind: "controller",
30
- clusterId: input.clusterId,
31
- holderId: input.holderId,
32
- ttlMs: input.ttlMs,
33
- grantedAtMs,
34
- expiresAtMs: grantedAtMs + input.ttlMs,
35
- metadata: cloneMetadata(input.metadata)
36
- });
37
- const result = await this.namespace.if(key, "Create", "==", 0).then(this.namespace.put(key).value(value).lease(leaseId)).commit();
38
- if (!result.succeeded) {
39
- await revokeLeaseQuietly(lease);
40
- const current = await this.getLeaseFromKey(key);
41
- return { acquired: false, lease: current };
42
- }
43
- this.trackLease(lease, {
44
- leaseId,
45
- key,
46
- ttlMs: input.ttlMs,
47
- ttlSeconds: ttlMsToSeconds(input.ttlMs),
48
- kind: "controller",
49
- clusterId: input.clusterId,
50
- holderId: input.holderId,
51
- metadata: cloneMetadata(input.metadata)
52
- });
53
- const parsed = parseLease(value);
54
- return { acquired: true, lease: parsed };
13
+ }
14
+ function assertPositiveTtl(ttlMs) {
15
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
16
+ throw new RangeError("ttlMs must be a positive safe integer");
55
17
  }
56
- async renewLease(leaseId, ttlMs) {
57
- assertNonEmpty(leaseId, "leaseId");
58
- assertPositiveTtl(ttlMs);
59
- const entry = this.leases.get(leaseId);
60
- if (!entry) {
61
- return false;
62
- }
63
- try {
64
- await entry.lease.keepaliveOnce();
65
- const renewedAtMs = Date.now();
66
- const leaseValue = {
67
- id: leaseId,
68
- kind: entry.kind,
69
- clusterId: entry.clusterId,
70
- holderId: entry.holderId,
71
- ttlMs,
72
- grantedAtMs: renewedAtMs,
73
- expiresAtMs: renewedAtMs + ttlMs,
74
- metadata: cloneMetadata(entry.metadata)
75
- };
76
- const value = entry.kind === "node-session" && entry.nodeSession ? JSON.stringify({ ...entry.nodeSession, lease: leaseValue }) : serializeLease(leaseValue);
77
- await this.namespace.put(entry.key).value(value).ignoreLease();
78
- entry.ttlMs = ttlMs;
79
- return true;
80
- } catch {
81
- this.leases.delete(leaseId);
82
- return false;
18
+ }
19
+ function assertNonNegativeTerm(term) {
20
+ if (term < 0n) {
21
+ throw new RangeError("primaryTerm must not be negative");
22
+ }
23
+ }
24
+ function assertNonNegativeSeq(seq, name) {
25
+ if (seq < 0n) {
26
+ throw new RangeError(`${name} must not be negative`);
27
+ }
28
+ }
29
+ function assertSubset(values, allowed, name) {
30
+ const allowedSet = new Set(allowed);
31
+ for (const value of values) {
32
+ if (!allowedSet.has(value)) {
33
+ throw new RangeError(`${name} contains node id '${value}' that is not configured for the replication group`);
83
34
  }
84
35
  }
85
- async releaseLease(leaseId) {
86
- assertNonEmpty(leaseId, "leaseId");
87
- const entry = this.leases.get(leaseId);
88
- if (!entry) {
89
- return false;
36
+ }
37
+ function assertPrimaryInGroup(primary, votingDataBearingNodeIds) {
38
+ if (!primary) return;
39
+ assertNonEmpty(primary.nodeId, "primary.nodeId");
40
+ if (!votingDataBearingNodeIds.includes(primary.nodeId)) {
41
+ throw new RangeError(`Primary node '${primary.nodeId}' is not configured for the replication group`);
42
+ }
43
+ }
44
+ function assertNoInSyncAdditions(previous, next) {
45
+ const previousSet = new Set(previous);
46
+ for (const nodeId of next) {
47
+ if (!previousSet.has(nodeId)) {
48
+ throw new RangeError(`Node '${nodeId}' cannot be added to the in-sync set without catch-up proof`);
90
49
  }
91
- this.leases.delete(leaseId);
92
- const currentValue = await this.namespace.get(entry.key).string();
93
- const currentLeaseId = currentValue ? parseLeaseIdForEntry(entry.kind, currentValue) : null;
94
- let released = false;
95
- if (currentLeaseId === leaseId && currentValue) {
96
- const result = await this.namespace.if(entry.key, "Value", "==", currentValue).then(this.namespace.delete().key(entry.key)).commit();
97
- released = result.succeeded === true;
50
+ }
51
+ }
52
+ function normaliseNodeIds(nodeIds, name) {
53
+ const seen = /* @__PURE__ */ new Set();
54
+ const result = [];
55
+ for (const nodeId of nodeIds) {
56
+ assertNonEmpty(nodeId, `${name} entry`);
57
+ if (seen.has(nodeId)) {
58
+ throw new RangeError(`${name} contains duplicate node id '${nodeId}'`);
98
59
  }
99
- await revokeLeaseQuietly(entry.lease);
100
- return released;
60
+ seen.add(nodeId);
61
+ result.push(nodeId);
101
62
  }
102
- async registerNodeSession(input) {
103
- assertNonEmpty(input.clusterId, "clusterId");
104
- assertNonEmpty(input.nodeId, "nodeId");
105
- assertPositiveTtl(input.ttlMs);
106
- const key = nodeSessionKey(input.clusterId, input.nodeId);
107
- const existingRawSession = await this.namespace.get(key).string();
108
- if (existingRawSession) {
109
- const existingSession = parseNodeSession(existingRawSession);
110
- if (existingSession.lease.expiresAtMs > Date.now()) {
111
- throw new CoordinatorError(`Node session '${input.nodeId}' is already registered`);
112
- }
63
+ return result;
64
+ }
65
+ function setMembership(values, nodeId, enabled) {
66
+ if (enabled === void 0) return values;
67
+ const next = values.filter((value) => value !== nodeId);
68
+ if (enabled) {
69
+ next.push(nodeId);
70
+ }
71
+ return next;
72
+ }
73
+ function removeNodeId(values, nodeId) {
74
+ return values.filter((value) => value !== nodeId);
75
+ }
76
+ function arraysEqual(left, right) {
77
+ if (left.length !== right.length) return false;
78
+ for (let i = 0; i < left.length; i++) {
79
+ if (left[i] !== right[i]) return false;
80
+ }
81
+ return true;
82
+ }
83
+ function markDisplacedPrimaryForRepair(state, displacedPrimaryId, nextPrimaryId) {
84
+ if (!displacedPrimaryId || displacedPrimaryId === nextPrimaryId) return;
85
+ state.inSyncNodeIds = removeNodeId(state.inSyncNodeIds, displacedPrimaryId);
86
+ state.repairingNodeIds = setMembership(state.repairingNodeIds, displacedPrimaryId, true);
87
+ }
88
+ function cloneCompatibility(compatibility) {
89
+ return compatibility ? { ...compatibility } : void 0;
90
+ }
91
+ function cloneMetadata(metadata) {
92
+ return metadata ? { ...metadata } : void 0;
93
+ }
94
+ function isEligiblePromotionSession(state, nodeId, session) {
95
+ if (!session) {
96
+ return false;
97
+ }
98
+ return session.dataBearing && session.voting && state.inSyncNodeIds.includes(nodeId) && compatibilityAllowsPromotion(state.compatibility, session.compatibility) && !state.drainingNodeIds.includes(nodeId) && !state.repairingNodeIds.includes(nodeId) && !state.faultedNodeIds.includes(nodeId);
99
+ }
100
+ function nextInSyncSetState(state, input, nowMs) {
101
+ const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds, "inSyncNodeIds");
102
+ assertSubset(inSyncNodeIds, state.votingDataBearingNodeIds, "inSyncNodeIds");
103
+ assertNoInSyncAdditions(state.inSyncNodeIds, inSyncNodeIds);
104
+ const durabilityPointSeq = input.durabilityPointSeq !== void 0 && input.durabilityPointSeq > state.durabilityPointSeq ? input.durabilityPointSeq : state.durabilityPointSeq;
105
+ if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && durabilityPointSeq === state.durabilityPointSeq) {
106
+ return state;
107
+ }
108
+ return { ...state, durabilityPointSeq, inSyncNodeIds, updatedAtMs: nowMs };
109
+ }
110
+ function nextAdmittedInSyncState(state, input, nowMs) {
111
+ assertNodeInGroup(state, input.nodeId);
112
+ if (state.currentPrimary?.nodeId !== input.sourceNodeId || state.drainingNodeIds.includes(input.nodeId) || state.faultedNodeIds.includes(input.nodeId) || input.appliedSeq < state.durabilityPointSeq) {
113
+ return state;
114
+ }
115
+ const inSyncNodeIds = state.inSyncNodeIds.includes(input.nodeId) ? state.inSyncNodeIds : [...state.inSyncNodeIds, input.nodeId];
116
+ const repairingNodeIds = removeNodeId(state.repairingNodeIds, input.nodeId);
117
+ if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && arraysEqual(repairingNodeIds, state.repairingNodeIds)) {
118
+ return state;
119
+ }
120
+ return { ...state, inSyncNodeIds, repairingNodeIds, updatedAtMs: nowMs };
121
+ }
122
+ function nextMaintenanceState(state, input, nowMs) {
123
+ assertNodeInGroup(state, input.nodeId);
124
+ const leavingService = input.draining === true || input.repairing === true || input.faulted === true;
125
+ return {
126
+ ...state,
127
+ drainingNodeIds: setMembership(state.drainingNodeIds, input.nodeId, input.draining),
128
+ repairingNodeIds: setMembership(state.repairingNodeIds, input.nodeId, input.repairing),
129
+ faultedNodeIds: setMembership(state.faultedNodeIds, input.nodeId, input.faulted),
130
+ inSyncNodeIds: leavingService ? removeNodeId(state.inSyncNodeIds, input.nodeId) : state.inSyncNodeIds,
131
+ updatedAtMs: nowMs
132
+ };
133
+ }
134
+ function assertNodeInGroup(state, nodeId) {
135
+ if (!state.votingDataBearingNodeIds.includes(nodeId)) {
136
+ throw new RangeError(`Node '${nodeId}' is not configured for the replication group`);
137
+ }
138
+ }
139
+ function buildReplicationGroupState(input, nowMs = Date.now()) {
140
+ assertNonEmpty(input.clusterId, "clusterId");
141
+ assertNonEmpty(input.groupId, "groupId");
142
+ const votingDataBearingNodeIds = normaliseNodeIds(input.votingDataBearingNodeIds, "votingDataBearingNodeIds");
143
+ const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds ?? [], "inSyncNodeIds");
144
+ const drainingNodeIds = normaliseNodeIds(input.drainingNodeIds ?? [], "drainingNodeIds");
145
+ const repairingNodeIds = normaliseNodeIds(input.repairingNodeIds ?? [], "repairingNodeIds");
146
+ const faultedNodeIds = normaliseNodeIds(input.faultedNodeIds ?? [], "faultedNodeIds");
147
+ assertNonNegativeTerm(input.primaryTerm ?? 0n);
148
+ assertNonNegativeSeq(input.durabilityPointSeq ?? 0n, "durabilityPointSeq");
149
+ assertPrimaryInGroup(input.currentPrimary ?? null, votingDataBearingNodeIds);
150
+ assertSubset(inSyncNodeIds, votingDataBearingNodeIds, "inSyncNodeIds");
151
+ assertSubset(drainingNodeIds, votingDataBearingNodeIds, "drainingNodeIds");
152
+ assertSubset(repairingNodeIds, votingDataBearingNodeIds, "repairingNodeIds");
153
+ assertSubset(faultedNodeIds, votingDataBearingNodeIds, "faultedNodeIds");
154
+ return {
155
+ clusterId: input.clusterId,
156
+ groupId: input.groupId,
157
+ votingDataBearingNodeIds,
158
+ currentPrimary: input.currentPrimary ? { ...input.currentPrimary } : null,
159
+ primaryTerm: input.primaryTerm ?? 0n,
160
+ durabilityPointSeq: input.durabilityPointSeq ?? 0n,
161
+ inSyncNodeIds,
162
+ drainingNodeIds,
163
+ repairingNodeIds,
164
+ faultedNodeIds,
165
+ compatibility: cloneCompatibility(input.compatibility),
166
+ updatedAtMs: nowMs
167
+ };
168
+ }
169
+ function cloneReplicationGroupState(state) {
170
+ return {
171
+ ...state,
172
+ currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
173
+ votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
174
+ durabilityPointSeq: state.durabilityPointSeq,
175
+ inSyncNodeIds: [...state.inSyncNodeIds],
176
+ drainingNodeIds: [...state.drainingNodeIds],
177
+ repairingNodeIds: [...state.repairingNodeIds],
178
+ faultedNodeIds: [...state.faultedNodeIds],
179
+ compatibility: cloneCompatibility(state.compatibility)
180
+ };
181
+ }
182
+
183
+ // src/replication/coordinator/etcd-codec.ts
184
+ function serializeLease(lease) {
185
+ return JSON.stringify(lease);
186
+ }
187
+ function parseLease(raw) {
188
+ const value = JSON.parse(raw);
189
+ return {
190
+ ...value,
191
+ metadata: cloneMetadata(value.metadata)
192
+ };
193
+ }
194
+ function parseNodeSession(raw) {
195
+ const value = JSON.parse(raw);
196
+ return {
197
+ ...value,
198
+ lease: parseLease(JSON.stringify(value.lease)),
199
+ groupIds: [...value.groupIds],
200
+ compatibility: cloneCompatibility(value.compatibility),
201
+ metadata: cloneMetadata(value.metadata)
202
+ };
203
+ }
204
+ function parseLeaseIdForEntry(kind, raw) {
205
+ try {
206
+ if (kind === "node-session") {
207
+ return parseNodeSession(raw).lease.id;
113
208
  }
114
- const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
115
- const leaseId = await lease.grant();
116
- const grantedAtMs = Date.now();
117
- const session = {
118
- clusterId: input.clusterId,
119
- nodeId: input.nodeId,
120
- lease: {
121
- id: leaseId,
122
- kind: "node-session",
123
- clusterId: input.clusterId,
124
- holderId: input.nodeId,
125
- ttlMs: input.ttlMs,
126
- grantedAtMs,
127
- expiresAtMs: grantedAtMs + input.ttlMs,
128
- metadata: cloneMetadata(input.metadata)
129
- },
130
- endpoint: input.endpoint,
131
- groupIds: [...input.groupIds ?? []],
132
- dataBearing: input.dataBearing ?? true,
133
- voting: input.voting ?? true,
134
- compatibility: cloneCompatibility(input.compatibility),
135
- metadata: cloneMetadata(input.metadata)
136
- };
137
- const rawSession = JSON.stringify(session);
138
- const transaction = existingRawSession ? this.namespace.if(key, "Value", "==", existingRawSession) : this.namespace.if(key, "Create", "==", 0);
139
- const result = await transaction.then(this.namespace.put(key).value(rawSession).lease(leaseId)).commit();
140
- if (!result.succeeded) {
141
- await revokeLeaseQuietly(lease);
142
- throw new CoordinatorError(`Node session '${input.nodeId}' registration conflicted with a concurrent write`);
209
+ return parseLease(raw).id;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ function serializeGroupState(state) {
215
+ const serialized = {
216
+ ...state,
217
+ currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
218
+ votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
219
+ primaryTerm: state.primaryTerm.toString(),
220
+ durabilityPointSeq: state.durabilityPointSeq.toString(),
221
+ inSyncNodeIds: [...state.inSyncNodeIds],
222
+ drainingNodeIds: [...state.drainingNodeIds],
223
+ repairingNodeIds: [...state.repairingNodeIds],
224
+ faultedNodeIds: [...state.faultedNodeIds],
225
+ compatibility: cloneCompatibility(state.compatibility)
226
+ };
227
+ return JSON.stringify(serialized);
228
+ }
229
+ function parseGroupState(raw) {
230
+ const value = JSON.parse(raw);
231
+ return {
232
+ ...value,
233
+ currentPrimary: value.currentPrimary ? { ...value.currentPrimary } : null,
234
+ votingDataBearingNodeIds: [...value.votingDataBearingNodeIds],
235
+ primaryTerm: BigInt(value.primaryTerm),
236
+ durabilityPointSeq: value.durabilityPointSeq !== void 0 ? BigInt(value.durabilityPointSeq) : 0n,
237
+ inSyncNodeIds: [...value.inSyncNodeIds],
238
+ drainingNodeIds: [...value.drainingNodeIds],
239
+ repairingNodeIds: [...value.repairingNodeIds],
240
+ faultedNodeIds: [...value.faultedNodeIds ?? []],
241
+ compatibility: cloneCompatibility(value.compatibility)
242
+ };
243
+ }
244
+
245
+ // src/replication/coordinator/etcd-connection.ts
246
+ function assertEtcdOptions(options) {
247
+ const hosts = Array.isArray(options.hosts) ? options.hosts : [options.hosts];
248
+ if (hosts.length === 0) {
249
+ throw new TypeError("hosts must contain at least one etcd endpoint");
250
+ }
251
+ for (const host of hosts) {
252
+ assertNonEmpty(host, "hosts entry");
253
+ if (!options.allowInsecure && !host.startsWith("https://")) {
254
+ throw new TypeError("production coordinator access requires https etcd endpoints");
143
255
  }
144
- this.trackLease(lease, {
145
- leaseId,
146
- key,
147
- ttlMs: input.ttlMs,
148
- ttlSeconds: ttlMsToSeconds(input.ttlMs),
149
- kind: "node-session",
150
- clusterId: input.clusterId,
151
- holderId: input.nodeId,
152
- metadata: cloneMetadata(input.metadata),
153
- nodeSession: {
154
- clusterId: session.clusterId,
155
- nodeId: session.nodeId,
156
- endpoint: session.endpoint,
157
- groupIds: [...session.groupIds],
158
- dataBearing: session.dataBearing,
159
- voting: session.voting,
160
- compatibility: cloneCompatibility(session.compatibility),
161
- metadata: cloneMetadata(session.metadata)
162
- }
163
- });
164
- return parseNodeSession(rawSession);
165
256
  }
166
- async getLiveNodeSession(clusterId, nodeId) {
167
- assertNonEmpty(clusterId, "clusterId");
168
- assertNonEmpty(nodeId, "nodeId");
169
- const value = await this.namespace.get(nodeSessionKey(clusterId, nodeId)).string();
170
- return value ? parseNodeSession(value) : null;
257
+ assertNonEmpty(options.keyPrefix, "keyPrefix");
258
+ if (!options.allowInsecure && !options.credentials) {
259
+ throw new TypeError("production coordinator access requires TLS credentials");
260
+ }
261
+ const hasMtlsIdentity = Boolean(options.credentials?.privateKey && options.credentials.certChain);
262
+ const hasPasswordAuth = Boolean(options.auth?.username && options.auth.password);
263
+ if (!options.allowInsecure && !hasMtlsIdentity && !hasPasswordAuth) {
264
+ throw new TypeError("production coordinator access requires an authenticated Sirannon identity");
265
+ }
266
+ }
267
+ function toEtcdOptions(options) {
268
+ const defaultCallTimeoutMs = options.defaultCallTimeoutMs;
269
+ const defaultCallOptions = defaultCallTimeoutMs ? () => ({ deadline: Date.now() + defaultCallTimeoutMs }) : void 0;
270
+ return {
271
+ hosts: options.hosts,
272
+ credentials: options.credentials,
273
+ auth: options.auth,
274
+ grpcOptions: options.grpcOptions,
275
+ dialTimeout: options.dialTimeoutMs,
276
+ defaultCallOptions
277
+ };
278
+ }
279
+ function normaliseKeyPrefix(prefix) {
280
+ const trimmed = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
281
+ if (trimmed.length === 0) {
282
+ throw new TypeError("keyPrefix must not resolve to the etcd root");
171
283
  }
172
- async deregisterNodeSession(clusterId, nodeId) {
173
- assertNonEmpty(clusterId, "clusterId");
174
- assertNonEmpty(nodeId, "nodeId");
175
- const key = nodeSessionKey(clusterId, nodeId);
176
- for (const [leaseId, entry] of this.leases) {
177
- if (entry.key === key) {
178
- await this.releaseLease(leaseId);
179
- }
180
- }
284
+ return `${trimmed}/`;
285
+ }
286
+ function controllerLeaseKey(clusterId) {
287
+ return `clusters/${encodeKey(clusterId)}/controller`;
288
+ }
289
+ function nodeSessionKey(clusterId, nodeId) {
290
+ return `clusters/${encodeKey(clusterId)}/nodes/${encodeKey(nodeId)}`;
291
+ }
292
+ function replicationGroupKey(clusterId, groupId) {
293
+ return `clusters/${encodeKey(clusterId)}/groups/${encodeKey(groupId)}`;
294
+ }
295
+ function ttlMsToSeconds(ttlMs) {
296
+ return Math.max(1, Math.ceil(ttlMs / 1e3));
297
+ }
298
+ function encodeKey(value) {
299
+ return encodeURIComponent(value);
300
+ }
301
+
302
+ // src/replication/coordinator/etcd-group-store.ts
303
+ var MAX_CAS_ATTEMPTS = 5;
304
+ var MAX_PROMOTION_ATTEMPTS = 3;
305
+ var EtcdGroupStore = class {
306
+ constructor(namespace, watchers, onWatcherError) {
307
+ this.namespace = namespace;
308
+ this.watchers = watchers;
309
+ this.onWatcherError = onWatcherError;
181
310
  }
182
311
  async setReplicationGroupState(input) {
183
312
  const state = buildReplicationGroupState(input);
@@ -247,16 +376,12 @@ var EtcdClusterCoordinator = class {
247
376
  if (input.durabilityPointSeq !== void 0) {
248
377
  assertNonNegativeSeq(input.durabilityPointSeq, "durabilityPointSeq");
249
378
  }
250
- const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds, "inSyncNodeIds");
251
- return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
252
- assertSubset(inSyncNodeIds, state.votingDataBearingNodeIds, "inSyncNodeIds");
253
- assertNoInSyncAdditions(state.inSyncNodeIds, inSyncNodeIds);
254
- const durabilityPointSeq = input.durabilityPointSeq !== void 0 && input.durabilityPointSeq > state.durabilityPointSeq ? input.durabilityPointSeq : state.durabilityPointSeq;
255
- if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && durabilityPointSeq === state.durabilityPointSeq) {
256
- return state;
257
- }
258
- return { ...state, durabilityPointSeq, inSyncNodeIds, updatedAtMs: Date.now() };
259
- });
379
+ normaliseNodeIds(input.inSyncNodeIds, "inSyncNodeIds");
380
+ return this.updateGroupStateWithRetry(
381
+ input.clusterId,
382
+ input.groupId,
383
+ (state) => nextInSyncSetState(state, input, Date.now())
384
+ );
260
385
  }
261
386
  async admitNodeToInSyncSet(input) {
262
387
  assertNonEmpty(input.clusterId, "clusterId");
@@ -264,44 +389,27 @@ var EtcdClusterCoordinator = class {
264
389
  assertNonEmpty(input.nodeId, "nodeId");
265
390
  assertNonEmpty(input.sourceNodeId, "sourceNodeId");
266
391
  assertNonNegativeSeq(input.appliedSeq, "appliedSeq");
267
- return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
268
- if (!state.votingDataBearingNodeIds.includes(input.nodeId)) {
269
- throw new RangeError(`Node '${input.nodeId}' is not configured for the replication group`);
270
- }
271
- if (state.currentPrimary?.nodeId !== input.sourceNodeId || state.drainingNodeIds.includes(input.nodeId) || state.faultedNodeIds.includes(input.nodeId) || input.appliedSeq < state.durabilityPointSeq) {
272
- return state;
273
- }
274
- const inSyncNodeIds = state.inSyncNodeIds.includes(input.nodeId) ? state.inSyncNodeIds : [...state.inSyncNodeIds, input.nodeId];
275
- const repairingNodeIds = removeNodeId(state.repairingNodeIds, input.nodeId);
276
- if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && arraysEqual(repairingNodeIds, state.repairingNodeIds)) {
277
- return state;
278
- }
279
- return { ...state, inSyncNodeIds, repairingNodeIds, updatedAtMs: Date.now() };
280
- });
392
+ return this.updateGroupStateWithRetry(
393
+ input.clusterId,
394
+ input.groupId,
395
+ (state) => nextAdmittedInSyncState(state, input, Date.now())
396
+ );
281
397
  }
282
398
  async updateNodeMaintenance(input) {
283
399
  assertNonEmpty(input.clusterId, "clusterId");
284
400
  assertNonEmpty(input.groupId, "groupId");
285
401
  assertNonEmpty(input.nodeId, "nodeId");
286
- return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
287
- if (!state.votingDataBearingNodeIds.includes(input.nodeId)) {
288
- throw new RangeError(`Node '${input.nodeId}' is not configured for the replication group`);
289
- }
290
- return {
291
- ...state,
292
- drainingNodeIds: setMembership(state.drainingNodeIds, input.nodeId, input.draining),
293
- repairingNodeIds: setMembership(state.repairingNodeIds, input.nodeId, input.repairing),
294
- faultedNodeIds: setMembership(state.faultedNodeIds, input.nodeId, input.faulted),
295
- inSyncNodeIds: input.draining === true || input.repairing === true || input.faulted === true ? removeNodeId(state.inSyncNodeIds, input.nodeId) : state.inSyncNodeIds,
296
- updatedAtMs: Date.now()
297
- };
298
- });
402
+ return this.updateGroupStateWithRetry(
403
+ input.clusterId,
404
+ input.groupId,
405
+ (state) => nextMaintenanceState(state, input, Date.now())
406
+ );
299
407
  }
300
408
  async promoteEligibleReplica(input) {
301
409
  assertNonEmpty(input.clusterId, "clusterId");
302
410
  assertNonEmpty(input.groupId, "groupId");
303
411
  const excludedNodeIds = new Set(input.excludeNodeIds ?? []);
304
- for (let attempt = 0; attempt < 3; attempt++) {
412
+ for (let attempt = 0; attempt < MAX_PROMOTION_ATTEMPTS; attempt++) {
305
413
  const state = await this.getReplicationGroupState(input.clusterId, input.groupId);
306
414
  if (!state) {
307
415
  throw new NoSafePrimaryError(`No replication group '${input.groupId}' is registered`);
@@ -332,35 +440,12 @@ var EtcdClusterCoordinator = class {
332
440
  }
333
441
  throw new NoSafePrimaryError(`No safe primary is available for replication group '${input.groupId}'`);
334
442
  }
335
- async close() {
336
- const watcherCancels = [];
337
- for (const watcher of this.watchers) {
338
- watcherCancels.push(watcher.cancel());
339
- }
340
- this.watchers.clear();
341
- await Promise.allSettled(watcherCancels);
342
- const leaseRevokes = [];
343
- for (const entry of this.leases.values()) {
344
- leaseRevokes.push(revokeLeaseQuietly(entry.lease));
345
- }
346
- this.leases.clear();
347
- await Promise.allSettled(leaseRevokes);
348
- this.client.close();
349
- }
350
- async getLeaseFromKey(key) {
351
- const value = await this.namespace.get(key).string();
352
- return value ? parseLease(value) : null;
353
- }
354
- trackLease(lease, entry) {
355
- const fullEntry = { ...entry, lease };
356
- this.leases.set(entry.leaseId, fullEntry);
357
- lease.on("lost", (err) => {
358
- this.leases.delete(entry.leaseId);
359
- this.onWatcherError?.(err instanceof Error ? err : new CoordinatorError(String(err)));
360
- });
443
+ async getLiveNodeSession(clusterId, nodeId) {
444
+ const value = await this.namespace.get(nodeSessionKey(clusterId, nodeId)).string();
445
+ return value ? parseNodeSession(value) : null;
361
446
  }
362
447
  async updateGroupStateWithRetry(clusterId, groupId, mutate) {
363
- for (let attempt = 0; attempt < 5; attempt++) {
448
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
364
449
  const state = await this.getReplicationGroupState(clusterId, groupId);
365
450
  if (!state) {
366
451
  return null;
@@ -387,257 +472,264 @@ var EtcdClusterCoordinator = class {
387
472
  return { updated: false, state: await this.getReplicationGroupState(previous.clusterId, previous.groupId) };
388
473
  }
389
474
  };
390
- function createEtcdCoordinator(options) {
391
- return new EtcdClusterCoordinator(options);
392
- }
393
- function assertEtcdOptions(options) {
394
- const hosts = Array.isArray(options.hosts) ? options.hosts : [options.hosts];
395
- if (hosts.length === 0) {
396
- throw new TypeError("hosts must contain at least one etcd endpoint");
475
+
476
+ // src/replication/coordinator/etcd.ts
477
+ var EtcdClusterCoordinator = class {
478
+ client;
479
+ namespace;
480
+ onWatcherError;
481
+ leases = /* @__PURE__ */ new Map();
482
+ grantedNodeSessionLeaseIds = /* @__PURE__ */ new Map();
483
+ watchers = /* @__PURE__ */ new Set();
484
+ groups;
485
+ constructor(options) {
486
+ assertEtcdOptions(options);
487
+ this.client = new Etcd3(toEtcdOptions(options));
488
+ this.namespace = this.client.namespace(normaliseKeyPrefix(options.keyPrefix));
489
+ this.onWatcherError = options.onWatcherError;
490
+ this.groups = new EtcdGroupStore(this.namespace, this.watchers, this.onWatcherError);
397
491
  }
398
- for (const host of hosts) {
399
- assertNonEmpty(host, "hosts entry");
400
- if (!options.allowInsecure && !host.startsWith("https://")) {
401
- throw new TypeError("production coordinator access requires https etcd endpoints");
492
+ async tryAcquireControllerLease(input) {
493
+ assertNonEmpty(input.clusterId, "clusterId");
494
+ assertNonEmpty(input.holderId, "holderId");
495
+ assertPositiveTtl(input.ttlMs);
496
+ const key = controllerLeaseKey(input.clusterId);
497
+ const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
498
+ const leaseId = await lease.grant();
499
+ const grantedAtMs = Date.now();
500
+ const value = serializeLease({
501
+ id: leaseId,
502
+ kind: "controller",
503
+ clusterId: input.clusterId,
504
+ holderId: input.holderId,
505
+ ttlMs: input.ttlMs,
506
+ grantedAtMs,
507
+ expiresAtMs: grantedAtMs + input.ttlMs,
508
+ metadata: cloneMetadata(input.metadata)
509
+ });
510
+ const result = await this.namespace.if(key, "Create", "==", 0).then(this.namespace.put(key).value(value).lease(leaseId)).commit();
511
+ if (!result.succeeded) {
512
+ await revokeLeaseQuietly(lease);
513
+ const current = await this.getLeaseFromKey(key);
514
+ return { acquired: false, lease: current };
402
515
  }
516
+ this.trackLease(lease, {
517
+ leaseId,
518
+ key,
519
+ ttlMs: input.ttlMs,
520
+ ttlSeconds: ttlMsToSeconds(input.ttlMs),
521
+ kind: "controller",
522
+ clusterId: input.clusterId,
523
+ holderId: input.holderId,
524
+ metadata: cloneMetadata(input.metadata)
525
+ });
526
+ const parsed = parseLease(value);
527
+ return { acquired: true, lease: parsed };
403
528
  }
404
- assertNonEmpty(options.keyPrefix, "keyPrefix");
405
- if (!options.allowInsecure && !options.credentials) {
406
- throw new TypeError("production coordinator access requires TLS credentials");
407
- }
408
- const hasMtlsIdentity = Boolean(options.credentials?.privateKey && options.credentials.certChain);
409
- const hasPasswordAuth = Boolean(options.auth?.username && options.auth.password);
410
- if (!options.allowInsecure && !hasMtlsIdentity && !hasPasswordAuth) {
411
- throw new TypeError("production coordinator access requires an authenticated Sirannon identity");
412
- }
413
- }
414
- function toEtcdOptions(options) {
415
- const defaultCallTimeoutMs = options.defaultCallTimeoutMs;
416
- const defaultCallOptions = defaultCallTimeoutMs ? () => ({ deadline: Date.now() + defaultCallTimeoutMs }) : void 0;
417
- return {
418
- hosts: options.hosts,
419
- credentials: options.credentials,
420
- auth: options.auth,
421
- grpcOptions: options.grpcOptions,
422
- dialTimeout: options.dialTimeoutMs,
423
- defaultCallOptions
424
- };
425
- }
426
- function normaliseKeyPrefix(prefix) {
427
- const trimmed = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
428
- if (trimmed.length === 0) {
429
- throw new TypeError("keyPrefix must not resolve to the etcd root");
430
- }
431
- return `${trimmed}/`;
432
- }
433
- function controllerLeaseKey(clusterId) {
434
- return `clusters/${encodeKey(clusterId)}/controller`;
435
- }
436
- function nodeSessionKey(clusterId, nodeId) {
437
- return `clusters/${encodeKey(clusterId)}/nodes/${encodeKey(nodeId)}`;
438
- }
439
- function replicationGroupKey(clusterId, groupId) {
440
- return `clusters/${encodeKey(clusterId)}/groups/${encodeKey(groupId)}`;
441
- }
442
- function encodeKey(value) {
443
- return encodeURIComponent(value);
444
- }
445
- function ttlMsToSeconds(ttlMs) {
446
- return Math.max(1, Math.ceil(ttlMs / 1e3));
447
- }
448
- function buildReplicationGroupState(input) {
449
- assertNonEmpty(input.clusterId, "clusterId");
450
- assertNonEmpty(input.groupId, "groupId");
451
- const votingDataBearingNodeIds = normaliseNodeIds(input.votingDataBearingNodeIds, "votingDataBearingNodeIds");
452
- const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds ?? [], "inSyncNodeIds");
453
- const drainingNodeIds = normaliseNodeIds(input.drainingNodeIds ?? [], "drainingNodeIds");
454
- const repairingNodeIds = normaliseNodeIds(input.repairingNodeIds ?? [], "repairingNodeIds");
455
- const faultedNodeIds = normaliseNodeIds(input.faultedNodeIds ?? [], "faultedNodeIds");
456
- assertNonNegativeTerm(input.primaryTerm ?? 0n);
457
- assertNonNegativeSeq(input.durabilityPointSeq ?? 0n, "durabilityPointSeq");
458
- assertPrimaryInGroup(input.currentPrimary ?? null, votingDataBearingNodeIds);
459
- assertSubset(inSyncNodeIds, votingDataBearingNodeIds, "inSyncNodeIds");
460
- assertSubset(drainingNodeIds, votingDataBearingNodeIds, "drainingNodeIds");
461
- assertSubset(repairingNodeIds, votingDataBearingNodeIds, "repairingNodeIds");
462
- assertSubset(faultedNodeIds, votingDataBearingNodeIds, "faultedNodeIds");
463
- return {
464
- clusterId: input.clusterId,
465
- groupId: input.groupId,
466
- votingDataBearingNodeIds,
467
- currentPrimary: input.currentPrimary ? { ...input.currentPrimary } : null,
468
- primaryTerm: input.primaryTerm ?? 0n,
469
- durabilityPointSeq: input.durabilityPointSeq ?? 0n,
470
- inSyncNodeIds,
471
- drainingNodeIds,
472
- repairingNodeIds,
473
- faultedNodeIds,
474
- compatibility: cloneCompatibility(input.compatibility),
475
- updatedAtMs: Date.now()
476
- };
477
- }
478
- function serializeLease(lease) {
479
- return JSON.stringify(lease);
480
- }
481
- function parseLease(raw) {
482
- const value = JSON.parse(raw);
483
- return {
484
- ...value,
485
- metadata: cloneMetadata(value.metadata)
486
- };
487
- }
488
- function parseNodeSession(raw) {
489
- const value = JSON.parse(raw);
490
- return {
491
- ...value,
492
- lease: parseLease(JSON.stringify(value.lease)),
493
- groupIds: [...value.groupIds],
494
- compatibility: cloneCompatibility(value.compatibility),
495
- metadata: cloneMetadata(value.metadata)
496
- };
497
- }
498
- function parseLeaseIdForEntry(kind, raw) {
499
- try {
500
- if (kind === "node-session") {
501
- return parseNodeSession(raw).lease.id;
529
+ async renewLease(leaseId, ttlMs) {
530
+ assertNonEmpty(leaseId, "leaseId");
531
+ assertPositiveTtl(ttlMs);
532
+ const entry = this.leases.get(leaseId);
533
+ if (!entry) {
534
+ return false;
535
+ }
536
+ try {
537
+ await entry.lease.keepaliveOnce();
538
+ } catch {
539
+ this.leases.delete(leaseId);
540
+ return false;
502
541
  }
503
- return parseLease(raw).id;
504
- } catch {
505
- return null;
542
+ const renewedAtMs = Date.now();
543
+ const leaseValue = {
544
+ id: leaseId,
545
+ kind: entry.kind,
546
+ clusterId: entry.clusterId,
547
+ holderId: entry.holderId,
548
+ ttlMs,
549
+ grantedAtMs: renewedAtMs,
550
+ expiresAtMs: renewedAtMs + ttlMs,
551
+ metadata: cloneMetadata(entry.metadata)
552
+ };
553
+ const value = entry.kind === "node-session" && entry.nodeSession ? JSON.stringify({ ...entry.nodeSession, lease: leaseValue }) : serializeLease(leaseValue);
554
+ let refreshed;
555
+ try {
556
+ const result = await this.namespace.if(entry.key, "Lease", "==", leaseId).then(this.namespace.put(entry.key).value(value).ignoreLease()).commit();
557
+ refreshed = result.succeeded === true;
558
+ } catch {
559
+ return true;
560
+ }
561
+ if (!refreshed) {
562
+ this.leases.delete(leaseId);
563
+ return false;
564
+ }
565
+ entry.ttlMs = ttlMs;
566
+ return true;
506
567
  }
507
- }
508
- function serializeGroupState(state) {
509
- const serialized = {
510
- ...state,
511
- currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
512
- votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
513
- primaryTerm: state.primaryTerm.toString(),
514
- durabilityPointSeq: state.durabilityPointSeq.toString(),
515
- inSyncNodeIds: [...state.inSyncNodeIds],
516
- drainingNodeIds: [...state.drainingNodeIds],
517
- repairingNodeIds: [...state.repairingNodeIds],
518
- faultedNodeIds: [...state.faultedNodeIds],
519
- compatibility: cloneCompatibility(state.compatibility)
520
- };
521
- return JSON.stringify(serialized);
522
- }
523
- function parseGroupState(raw) {
524
- const value = JSON.parse(raw);
525
- return {
526
- ...value,
527
- currentPrimary: value.currentPrimary ? { ...value.currentPrimary } : null,
528
- votingDataBearingNodeIds: [...value.votingDataBearingNodeIds],
529
- primaryTerm: BigInt(value.primaryTerm),
530
- durabilityPointSeq: value.durabilityPointSeq !== void 0 ? BigInt(value.durabilityPointSeq) : 0n,
531
- inSyncNodeIds: [...value.inSyncNodeIds],
532
- drainingNodeIds: [...value.drainingNodeIds],
533
- repairingNodeIds: [...value.repairingNodeIds],
534
- faultedNodeIds: [...value.faultedNodeIds ?? []],
535
- compatibility: cloneCompatibility(value.compatibility)
536
- };
537
- }
538
- function cloneReplicationGroupState(state) {
539
- return {
540
- ...state,
541
- currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
542
- votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
543
- durabilityPointSeq: state.durabilityPointSeq,
544
- inSyncNodeIds: [...state.inSyncNodeIds],
545
- drainingNodeIds: [...state.drainingNodeIds],
546
- repairingNodeIds: [...state.repairingNodeIds],
547
- faultedNodeIds: [...state.faultedNodeIds],
548
- compatibility: cloneCompatibility(state.compatibility)
549
- };
550
- }
551
- function isEligiblePromotionSession(state, nodeId, session) {
552
- if (!session) {
553
- return false;
568
+ async releaseLease(leaseId) {
569
+ assertNonEmpty(leaseId, "leaseId");
570
+ const entry = this.leases.get(leaseId);
571
+ if (!entry) {
572
+ return false;
573
+ }
574
+ this.leases.delete(leaseId);
575
+ const currentValue = await this.namespace.get(entry.key).string();
576
+ const currentLeaseId = currentValue ? parseLeaseIdForEntry(entry.kind, currentValue) : null;
577
+ let released = false;
578
+ if (currentLeaseId === leaseId && currentValue) {
579
+ const result = await this.namespace.if(entry.key, "Value", "==", currentValue).then(this.namespace.delete().key(entry.key)).commit();
580
+ released = result.succeeded === true;
581
+ }
582
+ await revokeLeaseQuietly(entry.lease);
583
+ return released;
554
584
  }
555
- return session.dataBearing && session.voting && state.inSyncNodeIds.includes(nodeId) && compatibilityAllowsPromotion(state.compatibility, session.compatibility) && !state.drainingNodeIds.includes(nodeId) && !state.repairingNodeIds.includes(nodeId) && !state.faultedNodeIds.includes(nodeId);
556
- }
557
- function normaliseNodeIds(nodeIds, name) {
558
- const seen = /* @__PURE__ */ new Set();
559
- const result = [];
560
- for (const nodeId of nodeIds) {
561
- assertNonEmpty(nodeId, `${name} entry`);
562
- if (seen.has(nodeId)) {
563
- throw new RangeError(`${name} contains duplicate node id '${nodeId}'`);
585
+ async registerNodeSession(input) {
586
+ assertNonEmpty(input.clusterId, "clusterId");
587
+ assertNonEmpty(input.nodeId, "nodeId");
588
+ assertPositiveTtl(input.ttlMs);
589
+ const key = nodeSessionKey(input.clusterId, input.nodeId);
590
+ const existingRawSession = await this.namespace.get(key).string();
591
+ if (existingRawSession) {
592
+ const existingSession = parseNodeSession(existingRawSession);
593
+ const supersedesOwnSession = this.grantedNodeSessionLeaseIds.get(key) === existingSession.lease.id;
594
+ if (existingSession.lease.expiresAtMs > Date.now() && !supersedesOwnSession) {
595
+ throw new CoordinatorError(`Node session '${input.nodeId}' is already registered`);
596
+ }
564
597
  }
565
- seen.add(nodeId);
566
- result.push(nodeId);
598
+ const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
599
+ const leaseId = await lease.grant();
600
+ const grantedAtMs = Date.now();
601
+ const session = {
602
+ clusterId: input.clusterId,
603
+ nodeId: input.nodeId,
604
+ lease: {
605
+ id: leaseId,
606
+ kind: "node-session",
607
+ clusterId: input.clusterId,
608
+ holderId: input.nodeId,
609
+ ttlMs: input.ttlMs,
610
+ grantedAtMs,
611
+ expiresAtMs: grantedAtMs + input.ttlMs,
612
+ metadata: cloneMetadata(input.metadata)
613
+ },
614
+ endpoint: input.endpoint,
615
+ groupIds: [...input.groupIds ?? []],
616
+ dataBearing: input.dataBearing ?? true,
617
+ voting: input.voting ?? true,
618
+ compatibility: cloneCompatibility(input.compatibility),
619
+ metadata: cloneMetadata(input.metadata)
620
+ };
621
+ const rawSession = JSON.stringify(session);
622
+ const transaction = existingRawSession ? this.namespace.if(key, "Value", "==", existingRawSession) : this.namespace.if(key, "Create", "==", 0);
623
+ const result = await transaction.then(this.namespace.put(key).value(rawSession).lease(leaseId)).commit();
624
+ if (!result.succeeded) {
625
+ await revokeLeaseQuietly(lease);
626
+ throw new CoordinatorError(`Node session '${input.nodeId}' registration conflicted with a concurrent write`);
627
+ }
628
+ await this.discardSupersededLeases(key, leaseId);
629
+ this.grantedNodeSessionLeaseIds.set(key, leaseId);
630
+ this.trackLease(lease, {
631
+ leaseId,
632
+ key,
633
+ ttlMs: input.ttlMs,
634
+ ttlSeconds: ttlMsToSeconds(input.ttlMs),
635
+ kind: "node-session",
636
+ clusterId: input.clusterId,
637
+ holderId: input.nodeId,
638
+ metadata: cloneMetadata(input.metadata),
639
+ nodeSession: {
640
+ clusterId: session.clusterId,
641
+ nodeId: session.nodeId,
642
+ endpoint: session.endpoint,
643
+ groupIds: [...session.groupIds],
644
+ dataBearing: session.dataBearing,
645
+ voting: session.voting,
646
+ compatibility: cloneCompatibility(session.compatibility),
647
+ metadata: cloneMetadata(session.metadata)
648
+ }
649
+ });
650
+ return parseNodeSession(rawSession);
567
651
  }
568
- return result;
569
- }
570
- function setMembership(values, nodeId, enabled) {
571
- if (enabled === void 0) return values;
572
- const next = values.filter((value) => value !== nodeId);
573
- if (enabled) {
574
- next.push(nodeId);
652
+ async getLiveNodeSession(clusterId, nodeId) {
653
+ assertNonEmpty(clusterId, "clusterId");
654
+ assertNonEmpty(nodeId, "nodeId");
655
+ const value = await this.namespace.get(nodeSessionKey(clusterId, nodeId)).string();
656
+ return value ? parseNodeSession(value) : null;
575
657
  }
576
- return next;
577
- }
578
- function markDisplacedPrimaryForRepair(state, displacedPrimaryId, nextPrimaryId) {
579
- if (!displacedPrimaryId || displacedPrimaryId === nextPrimaryId) return;
580
- state.inSyncNodeIds = removeNodeId(state.inSyncNodeIds, displacedPrimaryId);
581
- state.repairingNodeIds = setMembership(state.repairingNodeIds, displacedPrimaryId, true);
582
- }
583
- function removeNodeId(values, nodeId) {
584
- return values.filter((value) => value !== nodeId);
585
- }
586
- function assertSubset(values, allowed, name) {
587
- const allowedSet = new Set(allowed);
588
- for (const value of values) {
589
- if (!allowedSet.has(value)) {
590
- throw new RangeError(`${name} contains node id '${value}' that is not configured for the replication group`);
658
+ async deregisterNodeSession(clusterId, nodeId) {
659
+ assertNonEmpty(clusterId, "clusterId");
660
+ assertNonEmpty(nodeId, "nodeId");
661
+ const key = nodeSessionKey(clusterId, nodeId);
662
+ for (const [leaseId, entry] of this.leases) {
663
+ if (entry.key === key) {
664
+ await this.releaseLease(leaseId);
665
+ }
591
666
  }
592
667
  }
593
- }
594
- function assertPrimaryInGroup(primary, votingDataBearingNodeIds) {
595
- if (!primary) return;
596
- assertNonEmpty(primary.nodeId, "primary.nodeId");
597
- if (!votingDataBearingNodeIds.includes(primary.nodeId)) {
598
- throw new RangeError(`Primary node '${primary.nodeId}' is not configured for the replication group`);
668
+ setReplicationGroupState(input) {
669
+ return this.groups.setReplicationGroupState(input);
599
670
  }
600
- }
601
- function assertNonNegativeTerm(term) {
602
- if (term < 0n) {
603
- throw new RangeError("primaryTerm must not be negative");
671
+ getReplicationGroupState(clusterId, groupId) {
672
+ return this.groups.getReplicationGroupState(clusterId, groupId);
604
673
  }
605
- }
606
- function assertNonNegativeSeq(seq, name) {
607
- if (seq < 0n) {
608
- throw new RangeError(`${name} must not be negative`);
674
+ watchReplicationGroup(clusterId, groupId, watcher) {
675
+ return this.groups.watchReplicationGroup(clusterId, groupId, watcher);
609
676
  }
610
- }
611
- function assertNoInSyncAdditions(previous, next) {
612
- const previousSet = new Set(previous);
613
- for (const nodeId of next) {
614
- if (!previousSet.has(nodeId)) {
615
- throw new RangeError(`Node '${nodeId}' cannot be added to the in-sync set without catch-up proof`);
677
+ compareAndAdvancePrimaryTerm(input) {
678
+ return this.groups.compareAndAdvancePrimaryTerm(input);
679
+ }
680
+ updateInSyncSet(input) {
681
+ return this.groups.updateInSyncSet(input);
682
+ }
683
+ admitNodeToInSyncSet(input) {
684
+ return this.groups.admitNodeToInSyncSet(input);
685
+ }
686
+ updateNodeMaintenance(input) {
687
+ return this.groups.updateNodeMaintenance(input);
688
+ }
689
+ promoteEligibleReplica(input) {
690
+ return this.groups.promoteEligibleReplica(input);
691
+ }
692
+ async close() {
693
+ const watcherCancels = [];
694
+ for (const watcher of this.watchers) {
695
+ watcherCancels.push(watcher.cancel());
696
+ }
697
+ this.watchers.clear();
698
+ await Promise.allSettled(watcherCancels);
699
+ const leaseRevokes = [];
700
+ for (const entry of this.leases.values()) {
701
+ leaseRevokes.push(revokeLeaseQuietly(entry.lease));
616
702
  }
703
+ this.leases.clear();
704
+ this.grantedNodeSessionLeaseIds.clear();
705
+ await Promise.allSettled(leaseRevokes);
706
+ this.client.close();
617
707
  }
618
- }
619
- function arraysEqual(left, right) {
620
- if (left.length !== right.length) return false;
621
- for (let i = 0; i < left.length; i++) {
622
- if (left[i] !== right[i]) return false;
708
+ async discardSupersededLeases(key, keepLeaseId) {
709
+ const superseded = [];
710
+ for (const [leaseId, entry] of this.leases) {
711
+ if (entry.key === key && leaseId !== keepLeaseId) {
712
+ this.leases.delete(leaseId);
713
+ superseded.push(entry);
714
+ }
715
+ }
716
+ await Promise.allSettled(superseded.map((entry) => revokeLeaseQuietly(entry.lease)));
623
717
  }
624
- return true;
625
- }
626
- function cloneCompatibility(compatibility) {
627
- return compatibility ? { ...compatibility } : void 0;
628
- }
629
- function cloneMetadata(metadata) {
630
- return metadata ? { ...metadata } : void 0;
631
- }
632
- function assertPositiveTtl(ttlMs) {
633
- if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
634
- throw new RangeError("ttlMs must be a positive safe integer");
718
+ async getLeaseFromKey(key) {
719
+ const value = await this.namespace.get(key).string();
720
+ return value ? parseLease(value) : null;
635
721
  }
636
- }
637
- function assertNonEmpty(value, name) {
638
- if (value.length === 0) {
639
- throw new TypeError(`${name} must not be empty`);
722
+ trackLease(lease, entry) {
723
+ const fullEntry = { ...entry, lease };
724
+ this.leases.set(entry.leaseId, fullEntry);
725
+ lease.on("lost", (err) => {
726
+ this.leases.delete(entry.leaseId);
727
+ this.onWatcherError?.(err instanceof Error ? err : new CoordinatorError(String(err)));
728
+ });
640
729
  }
730
+ };
731
+ function createEtcdCoordinator(options) {
732
+ return new EtcdClusterCoordinator(options);
641
733
  }
642
734
  async function revokeLeaseQuietly(lease) {
643
735
  try {