@ferricstore/ferricstore 0.12.0 → 0.12.2

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 (52) hide show
  1. package/README.md +3 -0
  2. package/dist/langgraph.cjs +395 -125
  3. package/dist/langgraph.cjs.map +1 -1
  4. package/dist/langgraph.d.cts +17 -7
  5. package/dist/langgraph.d.ts +17 -7
  6. package/dist/langgraph.js +396 -126
  7. package/dist/langgraph.js.map +1 -1
  8. package/dist/openai-agents.cjs +161 -24
  9. package/dist/openai-agents.cjs.map +1 -1
  10. package/dist/openai-agents.d.cts +10 -4
  11. package/dist/openai-agents.d.ts +10 -4
  12. package/dist/openai-agents.js +161 -24
  13. package/dist/openai-agents.js.map +1 -1
  14. package/docs/agent-api/assets/hierarchy.js +1 -0
  15. package/docs/agent-api/assets/highlight.css +92 -0
  16. package/docs/agent-api/assets/icons.js +18 -0
  17. package/docs/agent-api/assets/icons.svg +1 -0
  18. package/docs/agent-api/assets/main.js +60 -0
  19. package/docs/agent-api/assets/navigation.js +1 -0
  20. package/docs/agent-api/assets/search.js +1 -0
  21. package/docs/agent-api/assets/style.css +1648 -0
  22. package/docs/agent-api/classes/langgraph.FerricStoreSaver.html +297 -0
  23. package/docs/agent-api/classes/langgraph.FerricStoreStore.html +298 -0
  24. package/docs/agent-api/classes/langgraph.LangGraphFlow.html +190 -0
  25. package/docs/agent-api/classes/langgraph.LangGraphFlowContext.html +158 -0
  26. package/docs/agent-api/classes/langgraph.LangGraphFlowRun.html +133 -0
  27. package/docs/agent-api/classes/openai-agents.FerricStoreSession.html +241 -0
  28. package/docs/agent-api/hierarchy.html +44 -0
  29. package/docs/agent-api/index.html +142 -0
  30. package/docs/agent-api/interfaces/langgraph.FerricFlowHandlerContext.html +94 -0
  31. package/docs/agent-api/interfaces/langgraph.FerricStoreCommandClient.html +77 -0
  32. package/docs/agent-api/interfaces/langgraph.FerricStoreLockOptions.html +81 -0
  33. package/docs/agent-api/interfaces/langgraph.FerricStoreSaverOptions.html +106 -0
  34. package/docs/agent-api/interfaces/langgraph.FerricStoreStoreOptions.html +98 -0
  35. package/docs/agent-api/interfaces/langgraph.InvokableLangGraph.html +83 -0
  36. package/docs/agent-api/interfaces/langgraph.LangGraphFlowOptions.html +116 -0
  37. package/docs/agent-api/interfaces/openai-agents.FerricStoreSessionOptions.html +114 -0
  38. package/docs/agent-api/interfaces/openai-agents.Session.html +208 -0
  39. package/docs/agent-api/interfaces/openai-agents.SessionHistoryRewriteAwareSession.html +230 -0
  40. package/docs/agent-api/interfaces/openai-agents.SessionHistoryTransactionAwareSession.html +237 -0
  41. package/docs/agent-api/modules/langgraph.html +44 -0
  42. package/docs/agent-api/modules/openai-agents.html +44 -0
  43. package/docs/agent-api/modules.html +35 -0
  44. package/docs/agent-api/types/langgraph.LangGraphChannelVersions.html +33 -0
  45. package/docs/agent-api/types/langgraph.LangGraphInvocationConfig.html +33 -0
  46. package/docs/agent-api/types/langgraph.LangGraphOutcomeMapper.html +50 -0
  47. package/docs/agent-api/types/langgraph.LangGraphPendingWrite.html +33 -0
  48. package/docs/agent-api/types/openai-agents.AgentInputItem.html +35 -0
  49. package/docs/agent-frameworks.md +25 -8
  50. package/docs/api/index.html +4 -1
  51. package/docs/api/media/agent-frameworks.md +25 -8
  52. package/package.json +5 -5
package/dist/langgraph.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/langgraph/checkpoint.ts
2
- import { createHash } from "crypto";
2
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
3
3
  import {
4
4
  BaseCheckpointSaver,
5
5
  copyCheckpoint
@@ -112,14 +112,44 @@ function integerResponse(value, name) {
112
112
  if (!Number.isSafeInteger(parsed)) throw new TypeError(`FerricStore returned an invalid ${name}`);
113
113
  return parsed;
114
114
  }
115
+ async function readAtomicValue(client, key, name) {
116
+ const value = await client.command("GET", key);
117
+ if (value == null) return void 0;
118
+ if (typeof value === "string") return Buffer.from(value, "utf8");
119
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value);
120
+ throw new TypeError(`FerricStore returned a non-binary ${name}`);
121
+ }
122
+ async function compareAndSetAtomicValue(client, key, expected, value) {
123
+ if (expected == null) {
124
+ const response2 = await client.command("SET", key, value, "NX");
125
+ if (response2 == null || response2 === false) return false;
126
+ if (response2 === true) return true;
127
+ return textResponse(response2, "SET NX response").toUpperCase() === "OK";
128
+ }
129
+ const response = await client.command("CAS", key, expected, value);
130
+ if (response == null || response === false) return false;
131
+ if (response === true) return true;
132
+ return integerResponse(response, "CAS response") === 1;
133
+ }
115
134
  async function withMutationLocks(client, keys, operation, options = {}) {
116
135
  const orderedKeys = [...new Set(keys)].sort();
117
- if (orderedKeys.length === 0) return await operation();
136
+ if (orderedKeys.length === 0) {
137
+ const signal = new AbortController().signal;
138
+ return await operation({
139
+ signal,
140
+ assertOwned: () => void 0,
141
+ publish: async (...args) => await additiveCommand(client, args),
142
+ compareAndSet: async (key, expected, value) => await compareAndSetAtomicValue(client, key, expected, value)
143
+ });
144
+ }
118
145
  const normalized = {
119
146
  lockRetryMs: positiveInteger(options.lockRetryMs, DEFAULT_LOCK_OPTIONS.lockRetryMs, "lockRetryMs"),
120
147
  lockTtlMs: positiveInteger(options.lockTtlMs, DEFAULT_LOCK_OPTIONS.lockTtlMs, "lockTtlMs"),
121
148
  lockWaitMs: nonNegativeInteger(options.lockWaitMs, DEFAULT_LOCK_OPTIONS.lockWaitMs, "lockWaitMs")
122
149
  };
150
+ if (normalized.lockRetryMs >= normalized.lockTtlMs) {
151
+ throw new TypeError("lockRetryMs must be less than lockTtlMs");
152
+ }
123
153
  const owner = randomUUID();
124
154
  const acquired = [];
125
155
  const deadline = performance.now() + normalized.lockWaitMs;
@@ -128,29 +158,81 @@ async function withMutationLocks(client, keys, operation, options = {}) {
128
158
  let releaseError;
129
159
  let result;
130
160
  let operationCompleted = false;
161
+ let conditionalCommitCompleted = false;
131
162
  const heartbeatAbort = new AbortController();
163
+ const ownershipAbort = new AbortController();
164
+ const lastExtended = /* @__PURE__ */ new Map();
165
+ const loseOwnership = (error) => {
166
+ const normalizedError = errorObject(error);
167
+ heartbeatError ??= normalizedError;
168
+ if (!ownershipAbort.signal.aborted) ownershipAbort.abort(normalizedError);
169
+ return normalizedError;
170
+ };
171
+ const assertOwned = () => {
172
+ if (heartbeatError != null) throw errorObject(heartbeatError);
173
+ if (ownershipAbort.signal.aborted) throw errorObject(ownershipAbort.signal.reason);
174
+ };
175
+ const renewOwned = async () => {
176
+ assertOwned();
177
+ for (const key of acquired) {
178
+ try {
179
+ const response = await client.command("EXTEND", key, owner, normalized.lockTtlMs);
180
+ if (integerResponse(response, "EXTEND response") !== 1) {
181
+ throw new Error(`lost FerricStore lock ${JSON.stringify(key)} while mutating data`);
182
+ }
183
+ lastExtended.set(key, performance.now());
184
+ } catch (error) {
185
+ throw loseOwnership(new Error(
186
+ `could not validate FerricStore lock ${JSON.stringify(key)} before mutating data`,
187
+ { cause: error }
188
+ ));
189
+ }
190
+ }
191
+ assertOwned();
192
+ };
193
+ const lease = {
194
+ signal: ownershipAbort.signal,
195
+ assertOwned,
196
+ publish: async (...args) => {
197
+ await renewOwned();
198
+ const response = await additiveCommand(client, args);
199
+ assertOwned();
200
+ return response;
201
+ },
202
+ compareAndSet: async (key, expected, value) => {
203
+ await renewOwned();
204
+ const committed = await compareAndSetAtomicValue(client, key, expected, value);
205
+ if (committed) conditionalCommitCompleted = true;
206
+ else assertOwned();
207
+ return committed;
208
+ }
209
+ };
132
210
  try {
133
211
  for (const key of orderedKeys) {
134
212
  while (!await tryAcquireLock(client, key, owner, normalized.lockTtlMs)) {
135
213
  if (performance.now() >= deadline) {
136
214
  throw new Error(`timed out acquiring FerricStore lock ${JSON.stringify(key)}`);
137
215
  }
216
+ await extendAcquiredLocks(client, acquired, owner, normalized.lockTtlMs);
138
217
  await delay(normalized.lockRetryMs);
139
218
  }
140
219
  acquired.push(key);
141
220
  }
221
+ await extendAcquiredLocks(client, acquired, owner, normalized.lockTtlMs);
222
+ for (const key of acquired) lastExtended.set(key, performance.now());
142
223
  const heartbeat = renewLocks(
143
224
  client,
144
225
  acquired,
145
226
  owner,
146
227
  normalized.lockTtlMs,
228
+ lastExtended,
147
229
  heartbeatAbort.signal,
148
230
  (error) => {
149
- heartbeatError ??= error;
231
+ loseOwnership(error);
150
232
  }
151
233
  );
152
234
  try {
153
- result = await operation();
235
+ result = await operation(lease);
154
236
  operationCompleted = true;
155
237
  } catch (error) {
156
238
  primaryError = error;
@@ -171,11 +253,32 @@ async function withMutationLocks(client, keys, operation, options = {}) {
171
253
  }
172
254
  }
173
255
  if (primaryError != null) throw errorObject(primaryError);
174
- if (heartbeatError != null) throw errorObject(heartbeatError);
175
- if (releaseError != null) throw errorObject(releaseError);
256
+ if (heartbeatError != null && !conditionalCommitCompleted) throw errorObject(heartbeatError);
257
+ if (releaseError != null && !(heartbeatError != null && conditionalCommitCompleted)) {
258
+ throw errorObject(releaseError);
259
+ }
176
260
  if (!operationCompleted) throw new Error("FerricStore mutation did not complete");
177
261
  return result;
178
262
  }
263
+ async function additiveCommand(client, args) {
264
+ const rawName = args[0];
265
+ const name = typeof rawName === "string" ? rawName.toUpperCase() : Buffer.isBuffer(rawName) || rawName instanceof Uint8Array ? Buffer.from(rawName).toString("utf8").toUpperCase() : "";
266
+ if (name !== "SADD" && name !== "ZADD") {
267
+ throw new TypeError("FerricStore mutation leases only publish add-only SADD or ZADD indexes");
268
+ }
269
+ if (name === "ZADD" && (args.length < 4 || args.length % 2 !== 0 || args.slice(2).some((value, index) => index % 2 === 0 && Number(value) !== 0))) {
270
+ throw new TypeError("FerricStore mutation leases only publish zero-score ZADD indexes");
271
+ }
272
+ return await client.command(...args);
273
+ }
274
+ async function extendAcquiredLocks(client, keys, owner, ttlMs) {
275
+ for (const key of keys) {
276
+ const response = await client.command("EXTEND", key, owner, ttlMs);
277
+ if (integerResponse(response, "EXTEND response") !== 1) {
278
+ throw new Error(`lost FerricStore lock ${JSON.stringify(key)} before mutating data`);
279
+ }
280
+ }
281
+ }
179
282
  async function tryAcquireLock(client, key, owner, ttlMs) {
180
283
  try {
181
284
  const response = await client.command("LOCK", key, owner, ttlMs);
@@ -185,10 +288,9 @@ async function tryAcquireLock(client, key, owner, ttlMs) {
185
288
  throw error;
186
289
  }
187
290
  }
188
- async function renewLocks(client, keys, owner, ttlMs, signal, onError) {
189
- const intervalMs = Math.max(Math.floor(ttlMs / 3), 10);
190
- const retryMs = Math.min(Math.max(Math.floor(intervalMs / 10), 10), 1e3);
191
- const lastExtended = new Map(keys.map((key) => [key, performance.now()]));
291
+ async function renewLocks(client, keys, owner, ttlMs, lastExtended, signal, onError) {
292
+ const intervalMs = Math.max(Math.floor(ttlMs / 3), 1);
293
+ const retryMs = Math.min(Math.max(Math.floor(intervalMs / 10), 1), 1e3);
192
294
  let waitMs = intervalMs;
193
295
  while (!signal.aborted) {
194
296
  try {
@@ -225,6 +327,8 @@ function errorObject(value) {
225
327
 
226
328
  // src/langgraph/checkpoint.ts
227
329
  var FORMAT_VERSION = 1;
330
+ var LEGACY_EPOCH_PREFIX = "legacy:";
331
+ var CURRENT_EPOCH_PREFIX = "v2:";
228
332
  var CHECKPOINT_FIELD_PREFIX = "checkpoint:";
229
333
  var WRITE_FIELD_PREFIX = "write:";
230
334
  var WRITES_INDEX = {
@@ -252,22 +356,18 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
252
356
  async getTuple(config) {
253
357
  const { checkpointNs, threadId } = identity(config);
254
358
  const key = this.threadKey(threadId, checkpointNs);
359
+ const epoch = await this.readThreadEpoch(threadId);
255
360
  let id = checkpointId(config);
256
361
  let record;
257
362
  if (id != null) {
258
- record = await this.readRecord(key, id);
363
+ record = await this.readRecordAtEpoch(key, id, epoch);
259
364
  } else {
260
- let offset = 0;
261
- while (true) {
262
- const values = arrayResponse(
263
- await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), offset, offset),
264
- "ZREVRANGE checkpoint response"
265
- );
266
- if (values.length === 0) return void 0;
267
- id = textResponse(values[0], "checkpoint ID");
268
- record = await this.readRecord(key, id);
269
- if (record != null) break;
270
- offset += 1;
365
+ for (const candidate of await this.checkpointIdsAtEpoch(key, epoch)) {
366
+ record = await this.readRecordAtEpoch(key, candidate, epoch);
367
+ if (record != null) {
368
+ id = candidate;
369
+ break;
370
+ }
271
371
  }
272
372
  }
273
373
  if (id == null || record == null) return void 0;
@@ -287,14 +387,15 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
287
387
  const checkpointNs = namespaceSpecified ? requiredText(configurable?.checkpoint_ns, "checkpoint_ns", true) : void 0;
288
388
  const matches = [];
289
389
  if (threadId == null) {
290
- await this.collectGlobal(matches, beforeId, expectedId, options.filter, limit);
390
+ await this.collectGlobal(matches, beforeId, expectedId, checkpointNs, options.filter, limit);
291
391
  } else {
292
392
  const keys = checkpointNs == null ? await this.threadKeys(threadId) : [this.threadKey(threadId, checkpointNs)];
293
393
  for (const key of keys) {
294
- const ids = expectedId == null ? await this.checkpointIds(key) : [expectedId];
394
+ const epoch = await this.readThreadEpoch(threadId);
395
+ const ids = expectedId == null ? await this.checkpointIdsAtEpoch(key, epoch) : [expectedId];
295
396
  for (const id of ids) {
296
397
  if (beforeId != null && id >= beforeId) continue;
297
- const record = await this.readRecord(key, id);
398
+ const record = await this.readRecordAtEpoch(key, id, epoch);
298
399
  if (record == null || !metadataMatches(record.metadata, options.filter)) continue;
299
400
  matches.push({ key, record });
300
401
  }
@@ -321,13 +422,24 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
321
422
  threadId
322
423
  };
323
424
  const key = this.threadKey(threadId, checkpointNs);
324
- await withMutationLocks(this.client, [this.threadLockKey(threadId)], async () => {
325
- const locator = checkpointLocator(checkpoint.id, key);
326
- await this.client.command("SADD", this.threadCatalogKey(threadId), key);
327
- await this.client.command("ZADD", this.threadLocatorCatalogKey(threadId), 0, locator);
328
- await this.client.command("ZADD", this.catalogKey(), 0, locator);
329
- await this.client.command("ZADD", this.checkpointIndexKey(key), 0, checkpoint.id);
330
- await this.client.command("HSET", key, checkpointField(checkpoint.id), await this.serialize(record));
425
+ await withMutationLocks(this.client, [this.threadLockKey(threadId)], async (lease) => {
426
+ const epoch = await this.ensureThreadEpoch(threadId, lease);
427
+ const locator = checkpointLocator(checkpoint.id, key, threadId);
428
+ await lease.publish("SADD", this.threadCatalogKey(threadId), key);
429
+ await lease.publish("ZADD", this.threadLocatorCatalogKey(threadId), 0, locator);
430
+ await lease.publish("ZADD", this.catalogKey(), 0, locator);
431
+ await lease.publish("ZADD", this.checkpointIndexKey(key), 0, checkpoint.id);
432
+ await lease.publish("ZADD", this.atomicCheckpointIndexKey(key, requireEpoch(epoch)), 0, checkpoint.id);
433
+ const dataKey = this.checkpointRecordKey(key, requireEpoch(epoch), checkpoint.id);
434
+ const value = await this.serialize(record);
435
+ for (let attempt = 0; attempt < 8; attempt += 1) {
436
+ const expected = await readAtomicValue(this.client, dataKey, "LangGraph atomic checkpoint record");
437
+ if (await lease.compareAndSet(dataKey, expected, value)) {
438
+ await this.assertCurrentEpoch(threadId, epoch);
439
+ return;
440
+ }
441
+ }
442
+ throw new Error("concurrent FerricStore LangGraph checkpoint mutation did not converge");
331
443
  }, this.lockOptions);
332
444
  return checkpointConfig(threadId, checkpointNs, checkpoint.id);
333
445
  }
@@ -349,35 +461,39 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
349
461
  return { index, value: await this.serialize(record) };
350
462
  }));
351
463
  const key = this.threadKey(threadId, checkpointNs);
352
- await withMutationLocks(this.client, [this.threadLockKey(threadId)], async () => {
353
- await this.client.command("SADD", this.threadCatalogKey(threadId), key);
464
+ await withMutationLocks(this.client, [this.threadLockKey(threadId)], async (lease) => {
465
+ const epoch = await this.ensureThreadEpoch(threadId, lease);
466
+ const epochValue = requireEpoch(epoch);
467
+ await lease.publish("SADD", this.threadCatalogKey(threadId), key);
354
468
  for (const snapshot of snapshots) {
355
- await this.client.command(
356
- snapshot.index < 0 ? "HSET" : "HSETNX",
357
- key,
358
- writeField(id, taskId, snapshot.index),
359
- snapshot.value
360
- );
469
+ const dataKey = this.pendingWriteKey(key, epochValue, id, taskId, snapshot.index);
470
+ const indexKey = this.atomicPendingWriteIndexKey(key, epochValue, id);
471
+ await lease.publish("ZADD", indexKey, 0, dataKey);
472
+ for (let attempt = 0; attempt < 8; attempt += 1) {
473
+ const expected = await readAtomicValue(this.client, dataKey, "LangGraph atomic pending write");
474
+ if (snapshot.index >= 0 && expected != null) break;
475
+ if (snapshot.index >= 0 && epoch.legacyFallback) {
476
+ const legacy = await this.client.command("HGET", key, writeField(id, taskId, snapshot.index));
477
+ if (legacy != null) break;
478
+ }
479
+ if (await lease.compareAndSet(dataKey, expected, snapshot.value)) break;
480
+ if (attempt === 7) {
481
+ throw new Error("concurrent FerricStore LangGraph pending-write mutation did not converge");
482
+ }
483
+ }
361
484
  }
485
+ await this.assertCurrentEpoch(threadId, epoch);
362
486
  }, this.lockOptions);
363
487
  }
364
488
  async deleteThread(threadId) {
365
489
  const normalized = requiredText(threadId, "threadId");
366
- await withMutationLocks(this.client, [this.threadLockKey(normalized)], async () => {
367
- for (const key of await this.threadKeys(normalized)) {
368
- await this.client.command("DEL", key, this.checkpointIndexKey(key));
369
- }
370
- const locatorKey = this.threadLocatorCatalogKey(normalized);
371
- while (true) {
372
- const locators = arrayResponse(
373
- await this.client.command("ZRANGE", locatorKey, 0, this.scanCount - 1),
374
- "thread checkpoint locator response"
375
- );
376
- if (locators.length === 0) break;
377
- await this.client.command("ZREM", this.catalogKey(), ...asArguments(locators));
378
- await this.client.command("ZREM", locatorKey, ...asArguments(locators));
490
+ await withMutationLocks(this.client, [this.threadLockKey(normalized)], async (lease) => {
491
+ for (let attempt = 0; attempt < 8; attempt += 1) {
492
+ const epoch = await this.ensureThreadEpoch(normalized, lease);
493
+ const next = Buffer.from(`${CURRENT_EPOCH_PREFIX}${randomUUID2()}`, "utf8");
494
+ if (await lease.compareAndSet(this.threadEpochKey(normalized), epoch.raw, next)) return;
379
495
  }
380
- await this.client.command("DEL", this.threadCatalogKey(normalized), locatorKey);
496
+ throw new Error("concurrent FerricStore LangGraph thread deletion did not converge");
381
497
  }, this.lockOptions);
382
498
  }
383
499
  catalogKey() {
@@ -399,6 +515,47 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
399
515
  checkpointIndexKey(threadKey) {
400
516
  return `${threadKey}:checkpoint-index`;
401
517
  }
518
+ threadEpochKey(threadId) {
519
+ return `${this.threadCatalogKey(threadId)}:atomic-epoch`;
520
+ }
521
+ atomicCheckpointIndexKey(threadKey, epoch) {
522
+ return `${threadKey}:atomic:${sha256(epoch)}:checkpoint-index`;
523
+ }
524
+ atomicPendingWriteIndexKey(threadKey, epoch, checkpointId2) {
525
+ return `${threadKey}:atomic:${sha256(epoch)}:writes:${encodeComponent(checkpointId2)}`;
526
+ }
527
+ checkpointRecordKey(threadKey, epoch, checkpointId2) {
528
+ return `${threadKey}:atomic:${sha256(epoch)}:checkpoint:${encodeComponent(checkpointId2)}`;
529
+ }
530
+ pendingWriteKey(threadKey, epoch, checkpointId2, taskId, index) {
531
+ return `${threadKey}:atomic:${sha256(epoch)}:write:${encodeComponent(checkpointId2)}:${encodeComponent(taskId)}:${index}`;
532
+ }
533
+ async readThreadEpoch(threadId) {
534
+ const raw = await readAtomicValue(this.client, this.threadEpochKey(threadId), "LangGraph thread epoch");
535
+ if (raw == null) return { legacyFallback: true, raw, value: void 0 };
536
+ const value = raw.toString("utf8");
537
+ if (!value.startsWith(LEGACY_EPOCH_PREFIX) && !value.startsWith(CURRENT_EPOCH_PREFIX)) {
538
+ throw new Error("unsupported or corrupt FerricStore LangGraph thread epoch");
539
+ }
540
+ return { legacyFallback: value.startsWith(LEGACY_EPOCH_PREFIX), raw, value };
541
+ }
542
+ async ensureThreadEpoch(threadId, lease) {
543
+ for (let attempt = 0; attempt < 8; attempt += 1) {
544
+ const epoch = await this.readThreadEpoch(threadId);
545
+ if (epoch.value != null) return epoch;
546
+ const raw = Buffer.from(`${LEGACY_EPOCH_PREFIX}${randomUUID2()}`, "utf8");
547
+ if (await lease.compareAndSet(this.threadEpochKey(threadId), void 0, raw)) {
548
+ return { legacyFallback: true, raw, value: raw.toString("utf8") };
549
+ }
550
+ }
551
+ throw new Error("concurrent FerricStore LangGraph epoch initialization did not converge");
552
+ }
553
+ async assertCurrentEpoch(threadId, expected) {
554
+ const current = await this.readThreadEpoch(threadId);
555
+ if (expected.raw == null || current.raw?.equals(expected.raw) !== true) {
556
+ throw new Error("FerricStore LangGraph thread epoch changed while committing data");
557
+ }
558
+ }
402
559
  async serialize(value) {
403
560
  const [type, data] = await this.serde.dumpsTyped(value);
404
561
  const typeBytes = Buffer.from(type, "utf8");
@@ -418,8 +575,16 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
418
575
  const type = bytes.subarray(2, typeLength + 2).toString("utf8");
419
576
  return await this.serde.loadsTyped(type, bytes.subarray(typeLength + 2));
420
577
  }
421
- async readRecord(key, id) {
422
- const value = await this.client.command("HGET", key, checkpointField(id));
578
+ async readRecordAtEpoch(key, id, epoch) {
579
+ let value;
580
+ if (epoch.value != null) {
581
+ value = await this.client.command("GET", this.checkpointRecordKey(key, epoch.value, id));
582
+ if (value == null && epoch.legacyFallback) {
583
+ value = await this.client.command("HGET", key, checkpointField(id));
584
+ }
585
+ } else {
586
+ value = await this.client.command("HGET", key, checkpointField(id));
587
+ }
423
588
  if (value == null) return void 0;
424
589
  const record = await this.deserialize(value, "LangGraph checkpoint record");
425
590
  if (record.formatVersion !== FORMAT_VERSION || record.checkpointId !== id) {
@@ -427,23 +592,45 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
427
592
  }
428
593
  return record;
429
594
  }
430
- async pendingWrites(key, id) {
431
- const fields = await this.scanHash(key, `${WRITE_FIELD_PREFIX}${encodeComponent(id)}:*`);
432
- const records = await Promise.all(fields.map(async ([, value]) => await this.deserialize(value, "LangGraph pending write")));
433
- for (const record of records) {
595
+ async pendingWritesAtEpoch(key, id, epoch) {
596
+ const records = /* @__PURE__ */ new Map();
597
+ if (epoch.value != null) {
598
+ const keys = arrayResponse(
599
+ await this.client.command("ZRANGE", this.atomicPendingWriteIndexKey(key, epoch.value, id), 0, -1),
600
+ "LangGraph atomic pending-write index response"
601
+ );
602
+ for (const rawKey of keys) {
603
+ const dataKey = textResponse(rawKey, "LangGraph pending-write key");
604
+ const value = await this.client.command("GET", dataKey);
605
+ if (value == null) continue;
606
+ const record = await this.deserialize(value, "LangGraph pending write");
607
+ records.set(pendingWriteIdentity(record), record);
608
+ }
609
+ }
610
+ if (epoch.legacyFallback) {
611
+ const fields = await this.scanHash(key, `${WRITE_FIELD_PREFIX}${encodeComponent(id)}:*`);
612
+ for (const [, value] of fields) {
613
+ const record = await this.deserialize(value, "LangGraph pending write");
614
+ const identity2 = pendingWriteIdentity(record);
615
+ if (!records.has(identity2)) records.set(identity2, record);
616
+ }
617
+ }
618
+ const ordered = [...records.values()];
619
+ for (const record of ordered) {
434
620
  if (record.formatVersion !== FORMAT_VERSION) {
435
621
  throw new Error("unsupported FerricStore LangGraph pending-write format");
436
622
  }
437
623
  }
438
- records.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.index - right.index);
439
- return records.map((record) => [record.taskId, record.channel, record.value]);
624
+ ordered.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.index - right.index);
625
+ return ordered.map((record) => [record.taskId, record.channel, record.value]);
440
626
  }
441
627
  async tupleFromRecord(key, record) {
628
+ const epoch = await this.readThreadEpoch(record.threadId);
442
629
  return {
443
630
  checkpoint: record.checkpoint,
444
631
  config: checkpointConfig(record.threadId, record.checkpointNs, record.checkpointId),
445
632
  metadata: record.metadata,
446
- pendingWrites: await this.pendingWrites(key, record.checkpointId),
633
+ pendingWrites: await this.pendingWritesAtEpoch(key, record.checkpointId, epoch),
447
634
  ...record.parentCheckpointId == null ? {} : { parentConfig: checkpointConfig(record.threadId, record.checkpointNs, record.parentCheckpointId) }
448
635
  };
449
636
  }
@@ -475,15 +662,27 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
475
662
  );
476
663
  return values.map((value) => textResponse(value, "thread checkpoint key")).sort();
477
664
  }
478
- async checkpointIds(key) {
479
- const values = arrayResponse(
480
- await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), 0, -1),
481
- "checkpoint index response"
482
- );
483
- return values.map((value) => textResponse(value, "checkpoint ID"));
665
+ async checkpointIdsAtEpoch(key, epoch) {
666
+ const ids = /* @__PURE__ */ new Set();
667
+ if (epoch.value != null) {
668
+ const values = arrayResponse(
669
+ await this.client.command("ZREVRANGE", this.atomicCheckpointIndexKey(key, epoch.value), 0, -1),
670
+ "atomic checkpoint index response"
671
+ );
672
+ for (const value of values) ids.add(textResponse(value, "checkpoint ID"));
673
+ }
674
+ if (epoch.legacyFallback) {
675
+ const values = arrayResponse(
676
+ await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), 0, -1),
677
+ "checkpoint index response"
678
+ );
679
+ for (const value of values) ids.add(textResponse(value, "checkpoint ID"));
680
+ }
681
+ return [...ids].sort((left, right) => right.localeCompare(left));
484
682
  }
485
- async collectGlobal(result, beforeId, expectedId, filter, limit) {
683
+ async collectGlobal(result, beforeId, expectedId, checkpointNs, filter, limit) {
486
684
  let offset = 0;
685
+ const seen = /* @__PURE__ */ new Set();
487
686
  while (limit == null || result.length < limit) {
488
687
  const values = arrayResponse(
489
688
  await this.client.command("ZREVRANGE", this.catalogKey(), offset, offset + this.scanCount - 1),
@@ -491,11 +690,24 @@ var FerricStoreSaver = class extends BaseCheckpointSaver {
491
690
  );
492
691
  if (values.length === 0) break;
493
692
  for (const value of values) {
494
- const { checkpointId: id, threadKey: key } = decodeCheckpointLocator(value);
693
+ const locator = decodeCheckpointLocator(value);
694
+ const { checkpointId: id, threadKey: key } = locator;
495
695
  if (expectedId != null && id !== expectedId) continue;
496
696
  if (beforeId != null && id >= beforeId) continue;
497
- const record = await this.readRecord(key, id);
498
- if (record == null || !metadataMatches(record.metadata, filter)) continue;
697
+ let record;
698
+ if (locator.threadId != null) {
699
+ record = await this.readRecordAtEpoch(key, id, await this.readThreadEpoch(locator.threadId));
700
+ } else {
701
+ const legacy = await this.client.command("HGET", key, checkpointField(id));
702
+ if (legacy != null) {
703
+ const candidate = await this.deserialize(legacy, "LangGraph checkpoint record");
704
+ record = await this.readRecordAtEpoch(key, id, await this.readThreadEpoch(candidate.threadId));
705
+ }
706
+ }
707
+ if (record == null || checkpointNs != null && record.checkpointNs !== checkpointNs || !metadataMatches(record.metadata, filter)) continue;
708
+ const identity2 = lengthPrefixed([record.threadId, record.checkpointNs, record.checkpointId]).toString("base64url");
709
+ if (seen.has(identity2)) continue;
710
+ seen.add(identity2);
499
711
  result.push({ key, record });
500
712
  if (limit != null && result.length >= limit) break;
501
713
  }
@@ -544,8 +756,18 @@ function writeField(id, taskId, index) {
544
756
  function encodeComponent(value) {
545
757
  return Buffer.from(value, "utf8").toString("base64url");
546
758
  }
547
- function checkpointLocator(id, threadKey) {
548
- return Buffer.concat([orderedText(id), Buffer.from(threadKey, "utf8")]);
759
+ function checkpointLocator(id, threadKey, threadId) {
760
+ if (threadId == null) return Buffer.concat([orderedText(id), Buffer.from(threadKey, "utf8")]);
761
+ const threadIdBytes = Buffer.from(threadId, "utf8");
762
+ const length = Buffer.allocUnsafe(4);
763
+ length.writeUInt32BE(threadIdBytes.length);
764
+ return Buffer.concat([
765
+ orderedText(id),
766
+ Buffer.from([0, 1]),
767
+ length,
768
+ threadIdBytes,
769
+ Buffer.from(threadKey, "utf8")
770
+ ]);
549
771
  }
550
772
  function decodeCheckpointLocator(value) {
551
773
  if (!(typeof value === "string" || Buffer.isBuffer(value) || value instanceof Uint8Array)) {
@@ -553,9 +775,21 @@ function decodeCheckpointLocator(value) {
553
775
  }
554
776
  const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
555
777
  const decoded = decodeOrderedText(bytes, 0);
556
- const threadKey = bytes.subarray(decoded.offset).toString("utf8");
778
+ let offset = decoded.offset;
779
+ let threadId;
780
+ if (bytes[offset] === 0 && bytes[offset + 1] === 1) {
781
+ offset += 2;
782
+ if (bytes.length < offset + 4) throw new Error("truncated FerricStore checkpoint locator");
783
+ const length = bytes.readUInt32BE(offset);
784
+ offset += 4;
785
+ if (bytes.length < offset + length) throw new Error("truncated FerricStore checkpoint locator thread ID");
786
+ threadId = bytes.subarray(offset, offset + length).toString("utf8");
787
+ offset += length;
788
+ if (threadId.length === 0) throw new Error("FerricStore checkpoint locator has an empty thread ID");
789
+ }
790
+ const threadKey = bytes.subarray(offset).toString("utf8");
557
791
  if (threadKey.length === 0) throw new Error("FerricStore checkpoint locator has an empty thread key");
558
- return { checkpointId: decoded.value, threadKey };
792
+ return { checkpointId: decoded.value, ...threadId == null ? {} : { threadId }, threadKey };
559
793
  }
560
794
  function orderedText(value) {
561
795
  const output = [];
@@ -598,12 +832,12 @@ function lengthPrefixed(values) {
598
832
  }
599
833
  return Buffer.concat(parts);
600
834
  }
601
- function asArguments(values) {
602
- return values.map((value) => {
603
- if (typeof value === "string" || typeof value === "number" || Buffer.isBuffer(value)) return value;
604
- if (value instanceof Uint8Array) return Buffer.from(value);
605
- throw new TypeError("FerricStore returned an invalid command argument");
606
- });
835
+ function requireEpoch(epoch) {
836
+ if (epoch.value == null) throw new Error("FerricStore LangGraph thread epoch is not initialized");
837
+ return epoch.value;
838
+ }
839
+ function pendingWriteIdentity(record) {
840
+ return lengthPrefixed([record.taskId, String(record.index)]).toString("base64url");
607
841
  }
608
842
 
609
843
  // src/langgraph/store.ts
@@ -613,6 +847,7 @@ import {
613
847
  } from "@langchain/langgraph";
614
848
  var FORMAT_VERSION2 = 1;
615
849
  var ITEM_FIELD_PREFIX = "item:";
850
+ var DELETED_ITEM = Buffer.from("ferricstore:langgraph:item:deleted:v2", "utf8");
616
851
  var FerricStoreStore = class extends BaseStore {
617
852
  client;
618
853
  keyPrefix;
@@ -635,7 +870,7 @@ var FerricStoreStore = class extends BaseStore {
635
870
  validatePut(operation);
636
871
  return this.itemLockKey(operation.namespace, operation.key);
637
872
  });
638
- return await withMutationLocks(this.client, lockKeys, async () => {
873
+ return await withMutationLocks(this.client, lockKeys, async (lease) => {
639
874
  const results = [];
640
875
  const puts = /* @__PURE__ */ new Map();
641
876
  for (const operation of operations) {
@@ -652,7 +887,7 @@ var FerricStoreStore = class extends BaseStore {
652
887
  throw new TypeError("unsupported LangGraph store operation");
653
888
  }
654
889
  }
655
- for (const operation of puts.values()) await this.putOperation(operation);
890
+ for (const operation of puts.values()) await this.putOperation(operation, lease);
656
891
  return results;
657
892
  }, this.lockOptions);
658
893
  }
@@ -663,47 +898,45 @@ var FerricStoreStore = class extends BaseStore {
663
898
  return `${this.keyPrefix}:{lgs:${sha2562(namespaceIdentity(namespace))}}:namespace`;
664
899
  }
665
900
  itemLockKey(namespace, key) {
666
- const keyBytes = Buffer.from(key, "utf8");
667
- return `${this.keyPrefix}:{lgsi:${sha2562(Buffer.concat([
668
- namespaceIdentity(namespace),
669
- uint64(keyBytes.length),
670
- keyBytes
671
- ]))}}:mutation-lock`;
901
+ return `${this.keyPrefix}:{lgsi:${itemIdentityDigest(namespace, key)}}:mutation-lock`;
902
+ }
903
+ itemDataKey(namespace, key) {
904
+ return `${this.keyPrefix}:{lgsi:${itemIdentityDigest(namespace, key)}}:atomic-item`;
672
905
  }
673
906
  async getOperation(operation) {
674
907
  validateNamespace(operation.namespace);
675
908
  if (typeof operation.key !== "string") throw new TypeError("store key must be text");
676
- const value = await this.client.command(
677
- "HGET",
678
- this.namespaceKey(operation.namespace),
679
- itemField(operation.key)
680
- );
681
- return value == null ? null : decodeItem(value);
909
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
910
+ return snapshot.record == null ? null : itemFromRecord(snapshot.record);
682
911
  }
683
- async putOperation(operation) {
912
+ async putOperation(operation, lease) {
684
913
  validatePut(operation);
685
- const namespaceKey = this.namespaceKey(operation.namespace);
686
- const field = itemField(operation.key);
914
+ const dataKey = this.itemDataKey(operation.namespace, operation.key);
687
915
  const locator = catalogMember(operation.namespace, operation.key);
688
916
  if (operation.value == null) {
689
- await this.client.command("HDEL", namespaceKey, field);
690
- await this.client.command("ZREM", this.catalogKey(), locator);
691
- return;
917
+ for (let attempt = 0; attempt < 8; attempt += 1) {
918
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
919
+ if (snapshot.expected?.equals(DELETED_ITEM) === true) return;
920
+ if (await lease.compareAndSet(dataKey, snapshot.expected, DELETED_ITEM)) return;
921
+ }
922
+ throw new Error("concurrent FerricStore LangGraph store deletion did not converge");
692
923
  }
693
924
  const storedValue = snapshotJsonValue(operation.value);
694
- const existing = await this.client.command("HGET", namespaceKey, field);
695
- const now = (/* @__PURE__ */ new Date()).toISOString();
696
- const createdAt = existing == null ? now : decodeItemRecord(existing).createdAt;
697
- const record = {
698
- createdAt,
699
- formatVersion: FORMAT_VERSION2,
700
- key: operation.key,
701
- namespace: [...operation.namespace],
702
- updatedAt: now,
703
- value: storedValue
704
- };
705
- await this.client.command("ZADD", this.catalogKey(), 0, locator);
706
- await this.client.command("HSET", namespaceKey, field, encodeItem(record));
925
+ await lease.publish("ZADD", this.catalogKey(), 0, locator);
926
+ for (let attempt = 0; attempt < 8; attempt += 1) {
927
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
928
+ const now = (/* @__PURE__ */ new Date()).toISOString();
929
+ const record = {
930
+ createdAt: snapshot.record?.createdAt ?? now,
931
+ formatVersion: FORMAT_VERSION2,
932
+ key: operation.key,
933
+ namespace: [...operation.namespace],
934
+ updatedAt: now,
935
+ value: storedValue
936
+ };
937
+ if (await lease.compareAndSet(dataKey, snapshot.expected, encodeItem(record))) return;
938
+ }
939
+ throw new Error("concurrent FerricStore LangGraph store mutation did not converge");
707
940
  }
708
941
  async searchOperation(operation) {
709
942
  validateNamespacePrefix(operation.namespacePrefix);
@@ -741,8 +974,23 @@ var FerricStoreStore = class extends BaseStore {
741
974
  return [...namespaces.values()].sort(compareNamespaces).slice(offset, offset + limit);
742
975
  }
743
976
  async readCatalogItem(namespace, key) {
744
- const value = await this.client.command("HGET", this.namespaceKey(namespace), itemField(key));
745
- return value == null ? null : decodeItem(value);
977
+ const snapshot = await this.readItemSnapshot(namespace, key);
978
+ return snapshot.record == null ? null : itemFromRecord(snapshot.record);
979
+ }
980
+ async readItemSnapshot(namespace, key) {
981
+ const expected = await readAtomicValue(
982
+ this.client,
983
+ this.itemDataKey(namespace, key),
984
+ "LangGraph atomic store item"
985
+ );
986
+ if (expected != null) {
987
+ return {
988
+ expected,
989
+ record: expected.equals(DELETED_ITEM) ? null : decodeItemRecord(expected)
990
+ };
991
+ }
992
+ const legacy = await this.client.command("HGET", this.namespaceKey(namespace), itemField(key));
993
+ return { expected, record: legacy == null ? null : decodeItemRecord(legacy) };
746
994
  }
747
995
  async *catalogLocators() {
748
996
  let offset = 0;
@@ -842,8 +1090,7 @@ function snapshotJsonValue(value) {
842
1090
  function encodeItem(record) {
843
1091
  return Buffer.from(JSON.stringify(record), "utf8");
844
1092
  }
845
- function decodeItem(value) {
846
- const record = decodeItemRecord(value);
1093
+ function itemFromRecord(record) {
847
1094
  return {
848
1095
  createdAt: new Date(record.createdAt),
849
1096
  key: record.key,
@@ -852,6 +1099,14 @@ function decodeItem(value) {
852
1099
  value: record.value
853
1100
  };
854
1101
  }
1102
+ function itemIdentityDigest(namespace, key) {
1103
+ const keyBytes = Buffer.from(key, "utf8");
1104
+ return sha2562(Buffer.concat([
1105
+ namespaceIdentity(namespace),
1106
+ uint64(keyBytes.length),
1107
+ keyBytes
1108
+ ]));
1109
+ }
855
1110
  function decodeItemRecord(value) {
856
1111
  if (!(typeof value === "string" || Buffer.isBuffer(value) || value instanceof Uint8Array)) {
857
1112
  throw new TypeError("FerricStore returned a non-binary LangGraph store item");
@@ -1118,13 +1373,22 @@ var LangGraphFlow = class {
1118
1373
  this.options = { ...options };
1119
1374
  }
1120
1375
  async config(flow, graphContext) {
1376
+ const invokeOptions = this.options.invokeOptions ?? {};
1121
1377
  const additional = await this.options.config?.(flow) ?? {};
1378
+ const baseConfigurable = invokeOptions.configurable ?? {};
1379
+ if (typeof baseConfigurable !== "object" || Array.isArray(baseConfigurable)) {
1380
+ throw new TypeError("LangGraph invokeOptions configurable must be an object");
1381
+ }
1122
1382
  const rawConfigurable = additional.configurable ?? {};
1123
- if (rawConfigurable == null || typeof rawConfigurable !== "object" || Array.isArray(rawConfigurable)) {
1383
+ if (typeof rawConfigurable !== "object" || Array.isArray(rawConfigurable)) {
1124
1384
  throw new TypeError("LangGraph config configurable must be an object");
1125
1385
  }
1386
+ const baseMetadata = invokeOptions.metadata ?? {};
1387
+ if (typeof baseMetadata !== "object" || Array.isArray(baseMetadata)) {
1388
+ throw new TypeError("LangGraph invokeOptions metadata must be an object");
1389
+ }
1126
1390
  const rawMetadata = additional.metadata ?? {};
1127
- if (rawMetadata == null || typeof rawMetadata !== "object" || Array.isArray(rawMetadata)) {
1391
+ if (typeof rawMetadata !== "object" || Array.isArray(rawMetadata)) {
1128
1392
  throw new TypeError("LangGraph config metadata must be an object");
1129
1393
  }
1130
1394
  const threadId = requireText(
@@ -1137,13 +1401,19 @@ var LangGraphFlow = class {
1137
1401
  "checkpointNs",
1138
1402
  true
1139
1403
  );
1140
- const context = graphContext === void 0 ? this.options.context == null ? new LangGraphFlowContext(flow, threadId, checkpointNs) : await this.options.context(flow) : graphContext;
1404
+ const context = graphContext === void 0 ? this.options.context != null ? await this.options.context(flow) : Object.hasOwn(additional, "context") ? additional.context : Object.hasOwn(invokeOptions, "context") ? invokeOptions.context : new LangGraphFlowContext(flow, threadId, checkpointNs) : graphContext;
1141
1405
  return {
1142
- ...this.options.invokeOptions,
1406
+ ...invokeOptions,
1143
1407
  ...additional,
1144
- configurable: { ...rawConfigurable, checkpoint_ns: checkpointNs, thread_id: threadId },
1408
+ configurable: {
1409
+ ...baseConfigurable,
1410
+ ...rawConfigurable,
1411
+ checkpoint_ns: checkpointNs,
1412
+ thread_id: threadId
1413
+ },
1145
1414
  context,
1146
1415
  metadata: {
1416
+ ...baseMetadata,
1147
1417
  ...rawMetadata,
1148
1418
  ferricflow_id: flow.id,
1149
1419
  ferricflow_state: flow.logicalState,
@@ -1159,7 +1429,7 @@ var LangGraphFlow = class {
1159
1429
  if ((this.options.recoverExisting ?? true) && this.graph.getState != null) {
1160
1430
  hasCheckpoint = snapshotHasCheckpoint(await this.graph.getState(config));
1161
1431
  }
1162
- input = hasCheckpoint ? null : await (this.options.input?.(flow) ?? flow.payload);
1432
+ input = hasCheckpoint ? null : this.options.input == null ? flow.payload : await this.options.input(flow);
1163
1433
  }
1164
1434
  const value = await this.graph.invoke(input, config);
1165
1435
  const configurable = config.configurable;