@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
@@ -139,14 +139,44 @@ function integerResponse(value, name) {
139
139
  if (!Number.isSafeInteger(parsed)) throw new TypeError(`FerricStore returned an invalid ${name}`);
140
140
  return parsed;
141
141
  }
142
+ async function readAtomicValue(client, key, name) {
143
+ const value = await client.command("GET", key);
144
+ if (value == null) return void 0;
145
+ if (typeof value === "string") return Buffer.from(value, "utf8");
146
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value);
147
+ throw new TypeError(`FerricStore returned a non-binary ${name}`);
148
+ }
149
+ async function compareAndSetAtomicValue(client, key, expected, value) {
150
+ if (expected == null) {
151
+ const response2 = await client.command("SET", key, value, "NX");
152
+ if (response2 == null || response2 === false) return false;
153
+ if (response2 === true) return true;
154
+ return textResponse(response2, "SET NX response").toUpperCase() === "OK";
155
+ }
156
+ const response = await client.command("CAS", key, expected, value);
157
+ if (response == null || response === false) return false;
158
+ if (response === true) return true;
159
+ return integerResponse(response, "CAS response") === 1;
160
+ }
142
161
  async function withMutationLocks(client, keys, operation, options = {}) {
143
162
  const orderedKeys = [...new Set(keys)].sort();
144
- if (orderedKeys.length === 0) return await operation();
163
+ if (orderedKeys.length === 0) {
164
+ const signal = new AbortController().signal;
165
+ return await operation({
166
+ signal,
167
+ assertOwned: () => void 0,
168
+ publish: async (...args) => await additiveCommand(client, args),
169
+ compareAndSet: async (key, expected, value) => await compareAndSetAtomicValue(client, key, expected, value)
170
+ });
171
+ }
145
172
  const normalized = {
146
173
  lockRetryMs: positiveInteger(options.lockRetryMs, DEFAULT_LOCK_OPTIONS.lockRetryMs, "lockRetryMs"),
147
174
  lockTtlMs: positiveInteger(options.lockTtlMs, DEFAULT_LOCK_OPTIONS.lockTtlMs, "lockTtlMs"),
148
175
  lockWaitMs: nonNegativeInteger(options.lockWaitMs, DEFAULT_LOCK_OPTIONS.lockWaitMs, "lockWaitMs")
149
176
  };
177
+ if (normalized.lockRetryMs >= normalized.lockTtlMs) {
178
+ throw new TypeError("lockRetryMs must be less than lockTtlMs");
179
+ }
150
180
  const owner = (0, import_node_crypto.randomUUID)();
151
181
  const acquired = [];
152
182
  const deadline = performance.now() + normalized.lockWaitMs;
@@ -155,29 +185,81 @@ async function withMutationLocks(client, keys, operation, options = {}) {
155
185
  let releaseError;
156
186
  let result;
157
187
  let operationCompleted = false;
188
+ let conditionalCommitCompleted = false;
158
189
  const heartbeatAbort = new AbortController();
190
+ const ownershipAbort = new AbortController();
191
+ const lastExtended = /* @__PURE__ */ new Map();
192
+ const loseOwnership = (error) => {
193
+ const normalizedError = errorObject(error);
194
+ heartbeatError ??= normalizedError;
195
+ if (!ownershipAbort.signal.aborted) ownershipAbort.abort(normalizedError);
196
+ return normalizedError;
197
+ };
198
+ const assertOwned = () => {
199
+ if (heartbeatError != null) throw errorObject(heartbeatError);
200
+ if (ownershipAbort.signal.aborted) throw errorObject(ownershipAbort.signal.reason);
201
+ };
202
+ const renewOwned = async () => {
203
+ assertOwned();
204
+ for (const key of acquired) {
205
+ try {
206
+ const response = await client.command("EXTEND", key, owner, normalized.lockTtlMs);
207
+ if (integerResponse(response, "EXTEND response") !== 1) {
208
+ throw new Error(`lost FerricStore lock ${JSON.stringify(key)} while mutating data`);
209
+ }
210
+ lastExtended.set(key, performance.now());
211
+ } catch (error) {
212
+ throw loseOwnership(new Error(
213
+ `could not validate FerricStore lock ${JSON.stringify(key)} before mutating data`,
214
+ { cause: error }
215
+ ));
216
+ }
217
+ }
218
+ assertOwned();
219
+ };
220
+ const lease = {
221
+ signal: ownershipAbort.signal,
222
+ assertOwned,
223
+ publish: async (...args) => {
224
+ await renewOwned();
225
+ const response = await additiveCommand(client, args);
226
+ assertOwned();
227
+ return response;
228
+ },
229
+ compareAndSet: async (key, expected, value) => {
230
+ await renewOwned();
231
+ const committed = await compareAndSetAtomicValue(client, key, expected, value);
232
+ if (committed) conditionalCommitCompleted = true;
233
+ else assertOwned();
234
+ return committed;
235
+ }
236
+ };
159
237
  try {
160
238
  for (const key of orderedKeys) {
161
239
  while (!await tryAcquireLock(client, key, owner, normalized.lockTtlMs)) {
162
240
  if (performance.now() >= deadline) {
163
241
  throw new Error(`timed out acquiring FerricStore lock ${JSON.stringify(key)}`);
164
242
  }
243
+ await extendAcquiredLocks(client, acquired, owner, normalized.lockTtlMs);
165
244
  await (0, import_promises.setTimeout)(normalized.lockRetryMs);
166
245
  }
167
246
  acquired.push(key);
168
247
  }
248
+ await extendAcquiredLocks(client, acquired, owner, normalized.lockTtlMs);
249
+ for (const key of acquired) lastExtended.set(key, performance.now());
169
250
  const heartbeat = renewLocks(
170
251
  client,
171
252
  acquired,
172
253
  owner,
173
254
  normalized.lockTtlMs,
255
+ lastExtended,
174
256
  heartbeatAbort.signal,
175
257
  (error) => {
176
- heartbeatError ??= error;
258
+ loseOwnership(error);
177
259
  }
178
260
  );
179
261
  try {
180
- result = await operation();
262
+ result = await operation(lease);
181
263
  operationCompleted = true;
182
264
  } catch (error) {
183
265
  primaryError = error;
@@ -198,11 +280,32 @@ async function withMutationLocks(client, keys, operation, options = {}) {
198
280
  }
199
281
  }
200
282
  if (primaryError != null) throw errorObject(primaryError);
201
- if (heartbeatError != null) throw errorObject(heartbeatError);
202
- if (releaseError != null) throw errorObject(releaseError);
283
+ if (heartbeatError != null && !conditionalCommitCompleted) throw errorObject(heartbeatError);
284
+ if (releaseError != null && !(heartbeatError != null && conditionalCommitCompleted)) {
285
+ throw errorObject(releaseError);
286
+ }
203
287
  if (!operationCompleted) throw new Error("FerricStore mutation did not complete");
204
288
  return result;
205
289
  }
290
+ async function additiveCommand(client, args) {
291
+ const rawName = args[0];
292
+ const name = typeof rawName === "string" ? rawName.toUpperCase() : Buffer.isBuffer(rawName) || rawName instanceof Uint8Array ? Buffer.from(rawName).toString("utf8").toUpperCase() : "";
293
+ if (name !== "SADD" && name !== "ZADD") {
294
+ throw new TypeError("FerricStore mutation leases only publish add-only SADD or ZADD indexes");
295
+ }
296
+ if (name === "ZADD" && (args.length < 4 || args.length % 2 !== 0 || args.slice(2).some((value, index) => index % 2 === 0 && Number(value) !== 0))) {
297
+ throw new TypeError("FerricStore mutation leases only publish zero-score ZADD indexes");
298
+ }
299
+ return await client.command(...args);
300
+ }
301
+ async function extendAcquiredLocks(client, keys, owner, ttlMs) {
302
+ for (const key of keys) {
303
+ const response = await client.command("EXTEND", key, owner, ttlMs);
304
+ if (integerResponse(response, "EXTEND response") !== 1) {
305
+ throw new Error(`lost FerricStore lock ${JSON.stringify(key)} before mutating data`);
306
+ }
307
+ }
308
+ }
206
309
  async function tryAcquireLock(client, key, owner, ttlMs) {
207
310
  try {
208
311
  const response = await client.command("LOCK", key, owner, ttlMs);
@@ -212,10 +315,9 @@ async function tryAcquireLock(client, key, owner, ttlMs) {
212
315
  throw error;
213
316
  }
214
317
  }
215
- async function renewLocks(client, keys, owner, ttlMs, signal, onError) {
216
- const intervalMs = Math.max(Math.floor(ttlMs / 3), 10);
217
- const retryMs = Math.min(Math.max(Math.floor(intervalMs / 10), 10), 1e3);
218
- const lastExtended = new Map(keys.map((key) => [key, performance.now()]));
318
+ async function renewLocks(client, keys, owner, ttlMs, lastExtended, signal, onError) {
319
+ const intervalMs = Math.max(Math.floor(ttlMs / 3), 1);
320
+ const retryMs = Math.min(Math.max(Math.floor(intervalMs / 10), 1), 1e3);
219
321
  let waitMs = intervalMs;
220
322
  while (!signal.aborted) {
221
323
  try {
@@ -252,6 +354,8 @@ function errorObject(value) {
252
354
 
253
355
  // src/langgraph/checkpoint.ts
254
356
  var FORMAT_VERSION = 1;
357
+ var LEGACY_EPOCH_PREFIX = "legacy:";
358
+ var CURRENT_EPOCH_PREFIX = "v2:";
255
359
  var CHECKPOINT_FIELD_PREFIX = "checkpoint:";
256
360
  var WRITE_FIELD_PREFIX = "write:";
257
361
  var WRITES_INDEX = {
@@ -279,22 +383,18 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
279
383
  async getTuple(config) {
280
384
  const { checkpointNs, threadId } = identity(config);
281
385
  const key = this.threadKey(threadId, checkpointNs);
386
+ const epoch = await this.readThreadEpoch(threadId);
282
387
  let id = checkpointId(config);
283
388
  let record;
284
389
  if (id != null) {
285
- record = await this.readRecord(key, id);
390
+ record = await this.readRecordAtEpoch(key, id, epoch);
286
391
  } else {
287
- let offset = 0;
288
- while (true) {
289
- const values = arrayResponse(
290
- await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), offset, offset),
291
- "ZREVRANGE checkpoint response"
292
- );
293
- if (values.length === 0) return void 0;
294
- id = textResponse(values[0], "checkpoint ID");
295
- record = await this.readRecord(key, id);
296
- if (record != null) break;
297
- offset += 1;
392
+ for (const candidate of await this.checkpointIdsAtEpoch(key, epoch)) {
393
+ record = await this.readRecordAtEpoch(key, candidate, epoch);
394
+ if (record != null) {
395
+ id = candidate;
396
+ break;
397
+ }
298
398
  }
299
399
  }
300
400
  if (id == null || record == null) return void 0;
@@ -314,14 +414,15 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
314
414
  const checkpointNs = namespaceSpecified ? requiredText(configurable?.checkpoint_ns, "checkpoint_ns", true) : void 0;
315
415
  const matches = [];
316
416
  if (threadId == null) {
317
- await this.collectGlobal(matches, beforeId, expectedId, options.filter, limit);
417
+ await this.collectGlobal(matches, beforeId, expectedId, checkpointNs, options.filter, limit);
318
418
  } else {
319
419
  const keys = checkpointNs == null ? await this.threadKeys(threadId) : [this.threadKey(threadId, checkpointNs)];
320
420
  for (const key of keys) {
321
- const ids = expectedId == null ? await this.checkpointIds(key) : [expectedId];
421
+ const epoch = await this.readThreadEpoch(threadId);
422
+ const ids = expectedId == null ? await this.checkpointIdsAtEpoch(key, epoch) : [expectedId];
322
423
  for (const id of ids) {
323
424
  if (beforeId != null && id >= beforeId) continue;
324
- const record = await this.readRecord(key, id);
425
+ const record = await this.readRecordAtEpoch(key, id, epoch);
325
426
  if (record == null || !metadataMatches(record.metadata, options.filter)) continue;
326
427
  matches.push({ key, record });
327
428
  }
@@ -348,13 +449,24 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
348
449
  threadId
349
450
  };
350
451
  const key = this.threadKey(threadId, checkpointNs);
351
- await withMutationLocks(this.client, [this.threadLockKey(threadId)], async () => {
352
- const locator = checkpointLocator(checkpoint.id, key);
353
- await this.client.command("SADD", this.threadCatalogKey(threadId), key);
354
- await this.client.command("ZADD", this.threadLocatorCatalogKey(threadId), 0, locator);
355
- await this.client.command("ZADD", this.catalogKey(), 0, locator);
356
- await this.client.command("ZADD", this.checkpointIndexKey(key), 0, checkpoint.id);
357
- await this.client.command("HSET", key, checkpointField(checkpoint.id), await this.serialize(record));
452
+ await withMutationLocks(this.client, [this.threadLockKey(threadId)], async (lease) => {
453
+ const epoch = await this.ensureThreadEpoch(threadId, lease);
454
+ const locator = checkpointLocator(checkpoint.id, key, threadId);
455
+ await lease.publish("SADD", this.threadCatalogKey(threadId), key);
456
+ await lease.publish("ZADD", this.threadLocatorCatalogKey(threadId), 0, locator);
457
+ await lease.publish("ZADD", this.catalogKey(), 0, locator);
458
+ await lease.publish("ZADD", this.checkpointIndexKey(key), 0, checkpoint.id);
459
+ await lease.publish("ZADD", this.atomicCheckpointIndexKey(key, requireEpoch(epoch)), 0, checkpoint.id);
460
+ const dataKey = this.checkpointRecordKey(key, requireEpoch(epoch), checkpoint.id);
461
+ const value = await this.serialize(record);
462
+ for (let attempt = 0; attempt < 8; attempt += 1) {
463
+ const expected = await readAtomicValue(this.client, dataKey, "LangGraph atomic checkpoint record");
464
+ if (await lease.compareAndSet(dataKey, expected, value)) {
465
+ await this.assertCurrentEpoch(threadId, epoch);
466
+ return;
467
+ }
468
+ }
469
+ throw new Error("concurrent FerricStore LangGraph checkpoint mutation did not converge");
358
470
  }, this.lockOptions);
359
471
  return checkpointConfig(threadId, checkpointNs, checkpoint.id);
360
472
  }
@@ -376,35 +488,39 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
376
488
  return { index, value: await this.serialize(record) };
377
489
  }));
378
490
  const key = this.threadKey(threadId, checkpointNs);
379
- await withMutationLocks(this.client, [this.threadLockKey(threadId)], async () => {
380
- await this.client.command("SADD", this.threadCatalogKey(threadId), key);
491
+ await withMutationLocks(this.client, [this.threadLockKey(threadId)], async (lease) => {
492
+ const epoch = await this.ensureThreadEpoch(threadId, lease);
493
+ const epochValue = requireEpoch(epoch);
494
+ await lease.publish("SADD", this.threadCatalogKey(threadId), key);
381
495
  for (const snapshot of snapshots) {
382
- await this.client.command(
383
- snapshot.index < 0 ? "HSET" : "HSETNX",
384
- key,
385
- writeField(id, taskId, snapshot.index),
386
- snapshot.value
387
- );
496
+ const dataKey = this.pendingWriteKey(key, epochValue, id, taskId, snapshot.index);
497
+ const indexKey = this.atomicPendingWriteIndexKey(key, epochValue, id);
498
+ await lease.publish("ZADD", indexKey, 0, dataKey);
499
+ for (let attempt = 0; attempt < 8; attempt += 1) {
500
+ const expected = await readAtomicValue(this.client, dataKey, "LangGraph atomic pending write");
501
+ if (snapshot.index >= 0 && expected != null) break;
502
+ if (snapshot.index >= 0 && epoch.legacyFallback) {
503
+ const legacy = await this.client.command("HGET", key, writeField(id, taskId, snapshot.index));
504
+ if (legacy != null) break;
505
+ }
506
+ if (await lease.compareAndSet(dataKey, expected, snapshot.value)) break;
507
+ if (attempt === 7) {
508
+ throw new Error("concurrent FerricStore LangGraph pending-write mutation did not converge");
509
+ }
510
+ }
388
511
  }
512
+ await this.assertCurrentEpoch(threadId, epoch);
389
513
  }, this.lockOptions);
390
514
  }
391
515
  async deleteThread(threadId) {
392
516
  const normalized = requiredText(threadId, "threadId");
393
- await withMutationLocks(this.client, [this.threadLockKey(normalized)], async () => {
394
- for (const key of await this.threadKeys(normalized)) {
395
- await this.client.command("DEL", key, this.checkpointIndexKey(key));
396
- }
397
- const locatorKey = this.threadLocatorCatalogKey(normalized);
398
- while (true) {
399
- const locators = arrayResponse(
400
- await this.client.command("ZRANGE", locatorKey, 0, this.scanCount - 1),
401
- "thread checkpoint locator response"
402
- );
403
- if (locators.length === 0) break;
404
- await this.client.command("ZREM", this.catalogKey(), ...asArguments(locators));
405
- await this.client.command("ZREM", locatorKey, ...asArguments(locators));
517
+ await withMutationLocks(this.client, [this.threadLockKey(normalized)], async (lease) => {
518
+ for (let attempt = 0; attempt < 8; attempt += 1) {
519
+ const epoch = await this.ensureThreadEpoch(normalized, lease);
520
+ const next = Buffer.from(`${CURRENT_EPOCH_PREFIX}${(0, import_node_crypto2.randomUUID)()}`, "utf8");
521
+ if (await lease.compareAndSet(this.threadEpochKey(normalized), epoch.raw, next)) return;
406
522
  }
407
- await this.client.command("DEL", this.threadCatalogKey(normalized), locatorKey);
523
+ throw new Error("concurrent FerricStore LangGraph thread deletion did not converge");
408
524
  }, this.lockOptions);
409
525
  }
410
526
  catalogKey() {
@@ -426,6 +542,47 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
426
542
  checkpointIndexKey(threadKey) {
427
543
  return `${threadKey}:checkpoint-index`;
428
544
  }
545
+ threadEpochKey(threadId) {
546
+ return `${this.threadCatalogKey(threadId)}:atomic-epoch`;
547
+ }
548
+ atomicCheckpointIndexKey(threadKey, epoch) {
549
+ return `${threadKey}:atomic:${sha256(epoch)}:checkpoint-index`;
550
+ }
551
+ atomicPendingWriteIndexKey(threadKey, epoch, checkpointId2) {
552
+ return `${threadKey}:atomic:${sha256(epoch)}:writes:${encodeComponent(checkpointId2)}`;
553
+ }
554
+ checkpointRecordKey(threadKey, epoch, checkpointId2) {
555
+ return `${threadKey}:atomic:${sha256(epoch)}:checkpoint:${encodeComponent(checkpointId2)}`;
556
+ }
557
+ pendingWriteKey(threadKey, epoch, checkpointId2, taskId, index) {
558
+ return `${threadKey}:atomic:${sha256(epoch)}:write:${encodeComponent(checkpointId2)}:${encodeComponent(taskId)}:${index}`;
559
+ }
560
+ async readThreadEpoch(threadId) {
561
+ const raw = await readAtomicValue(this.client, this.threadEpochKey(threadId), "LangGraph thread epoch");
562
+ if (raw == null) return { legacyFallback: true, raw, value: void 0 };
563
+ const value = raw.toString("utf8");
564
+ if (!value.startsWith(LEGACY_EPOCH_PREFIX) && !value.startsWith(CURRENT_EPOCH_PREFIX)) {
565
+ throw new Error("unsupported or corrupt FerricStore LangGraph thread epoch");
566
+ }
567
+ return { legacyFallback: value.startsWith(LEGACY_EPOCH_PREFIX), raw, value };
568
+ }
569
+ async ensureThreadEpoch(threadId, lease) {
570
+ for (let attempt = 0; attempt < 8; attempt += 1) {
571
+ const epoch = await this.readThreadEpoch(threadId);
572
+ if (epoch.value != null) return epoch;
573
+ const raw = Buffer.from(`${LEGACY_EPOCH_PREFIX}${(0, import_node_crypto2.randomUUID)()}`, "utf8");
574
+ if (await lease.compareAndSet(this.threadEpochKey(threadId), void 0, raw)) {
575
+ return { legacyFallback: true, raw, value: raw.toString("utf8") };
576
+ }
577
+ }
578
+ throw new Error("concurrent FerricStore LangGraph epoch initialization did not converge");
579
+ }
580
+ async assertCurrentEpoch(threadId, expected) {
581
+ const current = await this.readThreadEpoch(threadId);
582
+ if (expected.raw == null || current.raw?.equals(expected.raw) !== true) {
583
+ throw new Error("FerricStore LangGraph thread epoch changed while committing data");
584
+ }
585
+ }
429
586
  async serialize(value) {
430
587
  const [type, data] = await this.serde.dumpsTyped(value);
431
588
  const typeBytes = Buffer.from(type, "utf8");
@@ -445,8 +602,16 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
445
602
  const type = bytes.subarray(2, typeLength + 2).toString("utf8");
446
603
  return await this.serde.loadsTyped(type, bytes.subarray(typeLength + 2));
447
604
  }
448
- async readRecord(key, id) {
449
- const value = await this.client.command("HGET", key, checkpointField(id));
605
+ async readRecordAtEpoch(key, id, epoch) {
606
+ let value;
607
+ if (epoch.value != null) {
608
+ value = await this.client.command("GET", this.checkpointRecordKey(key, epoch.value, id));
609
+ if (value == null && epoch.legacyFallback) {
610
+ value = await this.client.command("HGET", key, checkpointField(id));
611
+ }
612
+ } else {
613
+ value = await this.client.command("HGET", key, checkpointField(id));
614
+ }
450
615
  if (value == null) return void 0;
451
616
  const record = await this.deserialize(value, "LangGraph checkpoint record");
452
617
  if (record.formatVersion !== FORMAT_VERSION || record.checkpointId !== id) {
@@ -454,23 +619,45 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
454
619
  }
455
620
  return record;
456
621
  }
457
- async pendingWrites(key, id) {
458
- const fields = await this.scanHash(key, `${WRITE_FIELD_PREFIX}${encodeComponent(id)}:*`);
459
- const records = await Promise.all(fields.map(async ([, value]) => await this.deserialize(value, "LangGraph pending write")));
460
- for (const record of records) {
622
+ async pendingWritesAtEpoch(key, id, epoch) {
623
+ const records = /* @__PURE__ */ new Map();
624
+ if (epoch.value != null) {
625
+ const keys = arrayResponse(
626
+ await this.client.command("ZRANGE", this.atomicPendingWriteIndexKey(key, epoch.value, id), 0, -1),
627
+ "LangGraph atomic pending-write index response"
628
+ );
629
+ for (const rawKey of keys) {
630
+ const dataKey = textResponse(rawKey, "LangGraph pending-write key");
631
+ const value = await this.client.command("GET", dataKey);
632
+ if (value == null) continue;
633
+ const record = await this.deserialize(value, "LangGraph pending write");
634
+ records.set(pendingWriteIdentity(record), record);
635
+ }
636
+ }
637
+ if (epoch.legacyFallback) {
638
+ const fields = await this.scanHash(key, `${WRITE_FIELD_PREFIX}${encodeComponent(id)}:*`);
639
+ for (const [, value] of fields) {
640
+ const record = await this.deserialize(value, "LangGraph pending write");
641
+ const identity2 = pendingWriteIdentity(record);
642
+ if (!records.has(identity2)) records.set(identity2, record);
643
+ }
644
+ }
645
+ const ordered = [...records.values()];
646
+ for (const record of ordered) {
461
647
  if (record.formatVersion !== FORMAT_VERSION) {
462
648
  throw new Error("unsupported FerricStore LangGraph pending-write format");
463
649
  }
464
650
  }
465
- records.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.index - right.index);
466
- return records.map((record) => [record.taskId, record.channel, record.value]);
651
+ ordered.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.index - right.index);
652
+ return ordered.map((record) => [record.taskId, record.channel, record.value]);
467
653
  }
468
654
  async tupleFromRecord(key, record) {
655
+ const epoch = await this.readThreadEpoch(record.threadId);
469
656
  return {
470
657
  checkpoint: record.checkpoint,
471
658
  config: checkpointConfig(record.threadId, record.checkpointNs, record.checkpointId),
472
659
  metadata: record.metadata,
473
- pendingWrites: await this.pendingWrites(key, record.checkpointId),
660
+ pendingWrites: await this.pendingWritesAtEpoch(key, record.checkpointId, epoch),
474
661
  ...record.parentCheckpointId == null ? {} : { parentConfig: checkpointConfig(record.threadId, record.checkpointNs, record.parentCheckpointId) }
475
662
  };
476
663
  }
@@ -502,15 +689,27 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
502
689
  );
503
690
  return values.map((value) => textResponse(value, "thread checkpoint key")).sort();
504
691
  }
505
- async checkpointIds(key) {
506
- const values = arrayResponse(
507
- await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), 0, -1),
508
- "checkpoint index response"
509
- );
510
- return values.map((value) => textResponse(value, "checkpoint ID"));
692
+ async checkpointIdsAtEpoch(key, epoch) {
693
+ const ids = /* @__PURE__ */ new Set();
694
+ if (epoch.value != null) {
695
+ const values = arrayResponse(
696
+ await this.client.command("ZREVRANGE", this.atomicCheckpointIndexKey(key, epoch.value), 0, -1),
697
+ "atomic checkpoint index response"
698
+ );
699
+ for (const value of values) ids.add(textResponse(value, "checkpoint ID"));
700
+ }
701
+ if (epoch.legacyFallback) {
702
+ const values = arrayResponse(
703
+ await this.client.command("ZREVRANGE", this.checkpointIndexKey(key), 0, -1),
704
+ "checkpoint index response"
705
+ );
706
+ for (const value of values) ids.add(textResponse(value, "checkpoint ID"));
707
+ }
708
+ return [...ids].sort((left, right) => right.localeCompare(left));
511
709
  }
512
- async collectGlobal(result, beforeId, expectedId, filter, limit) {
710
+ async collectGlobal(result, beforeId, expectedId, checkpointNs, filter, limit) {
513
711
  let offset = 0;
712
+ const seen = /* @__PURE__ */ new Set();
514
713
  while (limit == null || result.length < limit) {
515
714
  const values = arrayResponse(
516
715
  await this.client.command("ZREVRANGE", this.catalogKey(), offset, offset + this.scanCount - 1),
@@ -518,11 +717,24 @@ var FerricStoreSaver = class extends import_langgraph.BaseCheckpointSaver {
518
717
  );
519
718
  if (values.length === 0) break;
520
719
  for (const value of values) {
521
- const { checkpointId: id, threadKey: key } = decodeCheckpointLocator(value);
720
+ const locator = decodeCheckpointLocator(value);
721
+ const { checkpointId: id, threadKey: key } = locator;
522
722
  if (expectedId != null && id !== expectedId) continue;
523
723
  if (beforeId != null && id >= beforeId) continue;
524
- const record = await this.readRecord(key, id);
525
- if (record == null || !metadataMatches(record.metadata, filter)) continue;
724
+ let record;
725
+ if (locator.threadId != null) {
726
+ record = await this.readRecordAtEpoch(key, id, await this.readThreadEpoch(locator.threadId));
727
+ } else {
728
+ const legacy = await this.client.command("HGET", key, checkpointField(id));
729
+ if (legacy != null) {
730
+ const candidate = await this.deserialize(legacy, "LangGraph checkpoint record");
731
+ record = await this.readRecordAtEpoch(key, id, await this.readThreadEpoch(candidate.threadId));
732
+ }
733
+ }
734
+ if (record == null || checkpointNs != null && record.checkpointNs !== checkpointNs || !metadataMatches(record.metadata, filter)) continue;
735
+ const identity2 = lengthPrefixed([record.threadId, record.checkpointNs, record.checkpointId]).toString("base64url");
736
+ if (seen.has(identity2)) continue;
737
+ seen.add(identity2);
526
738
  result.push({ key, record });
527
739
  if (limit != null && result.length >= limit) break;
528
740
  }
@@ -571,8 +783,18 @@ function writeField(id, taskId, index) {
571
783
  function encodeComponent(value) {
572
784
  return Buffer.from(value, "utf8").toString("base64url");
573
785
  }
574
- function checkpointLocator(id, threadKey) {
575
- return Buffer.concat([orderedText(id), Buffer.from(threadKey, "utf8")]);
786
+ function checkpointLocator(id, threadKey, threadId) {
787
+ if (threadId == null) return Buffer.concat([orderedText(id), Buffer.from(threadKey, "utf8")]);
788
+ const threadIdBytes = Buffer.from(threadId, "utf8");
789
+ const length = Buffer.allocUnsafe(4);
790
+ length.writeUInt32BE(threadIdBytes.length);
791
+ return Buffer.concat([
792
+ orderedText(id),
793
+ Buffer.from([0, 1]),
794
+ length,
795
+ threadIdBytes,
796
+ Buffer.from(threadKey, "utf8")
797
+ ]);
576
798
  }
577
799
  function decodeCheckpointLocator(value) {
578
800
  if (!(typeof value === "string" || Buffer.isBuffer(value) || value instanceof Uint8Array)) {
@@ -580,9 +802,21 @@ function decodeCheckpointLocator(value) {
580
802
  }
581
803
  const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
582
804
  const decoded = decodeOrderedText(bytes, 0);
583
- const threadKey = bytes.subarray(decoded.offset).toString("utf8");
805
+ let offset = decoded.offset;
806
+ let threadId;
807
+ if (bytes[offset] === 0 && bytes[offset + 1] === 1) {
808
+ offset += 2;
809
+ if (bytes.length < offset + 4) throw new Error("truncated FerricStore checkpoint locator");
810
+ const length = bytes.readUInt32BE(offset);
811
+ offset += 4;
812
+ if (bytes.length < offset + length) throw new Error("truncated FerricStore checkpoint locator thread ID");
813
+ threadId = bytes.subarray(offset, offset + length).toString("utf8");
814
+ offset += length;
815
+ if (threadId.length === 0) throw new Error("FerricStore checkpoint locator has an empty thread ID");
816
+ }
817
+ const threadKey = bytes.subarray(offset).toString("utf8");
584
818
  if (threadKey.length === 0) throw new Error("FerricStore checkpoint locator has an empty thread key");
585
- return { checkpointId: decoded.value, threadKey };
819
+ return { checkpointId: decoded.value, ...threadId == null ? {} : { threadId }, threadKey };
586
820
  }
587
821
  function orderedText(value) {
588
822
  const output = [];
@@ -625,12 +859,12 @@ function lengthPrefixed(values) {
625
859
  }
626
860
  return Buffer.concat(parts);
627
861
  }
628
- function asArguments(values) {
629
- return values.map((value) => {
630
- if (typeof value === "string" || typeof value === "number" || Buffer.isBuffer(value)) return value;
631
- if (value instanceof Uint8Array) return Buffer.from(value);
632
- throw new TypeError("FerricStore returned an invalid command argument");
633
- });
862
+ function requireEpoch(epoch) {
863
+ if (epoch.value == null) throw new Error("FerricStore LangGraph thread epoch is not initialized");
864
+ return epoch.value;
865
+ }
866
+ function pendingWriteIdentity(record) {
867
+ return lengthPrefixed([record.taskId, String(record.index)]).toString("base64url");
634
868
  }
635
869
 
636
870
  // src/langgraph/store.ts
@@ -638,6 +872,7 @@ var import_node_crypto3 = require("crypto");
638
872
  var import_langgraph2 = require("@langchain/langgraph");
639
873
  var FORMAT_VERSION2 = 1;
640
874
  var ITEM_FIELD_PREFIX = "item:";
875
+ var DELETED_ITEM = Buffer.from("ferricstore:langgraph:item:deleted:v2", "utf8");
641
876
  var FerricStoreStore = class extends import_langgraph2.BaseStore {
642
877
  client;
643
878
  keyPrefix;
@@ -660,7 +895,7 @@ var FerricStoreStore = class extends import_langgraph2.BaseStore {
660
895
  validatePut(operation);
661
896
  return this.itemLockKey(operation.namespace, operation.key);
662
897
  });
663
- return await withMutationLocks(this.client, lockKeys, async () => {
898
+ return await withMutationLocks(this.client, lockKeys, async (lease) => {
664
899
  const results = [];
665
900
  const puts = /* @__PURE__ */ new Map();
666
901
  for (const operation of operations) {
@@ -677,7 +912,7 @@ var FerricStoreStore = class extends import_langgraph2.BaseStore {
677
912
  throw new TypeError("unsupported LangGraph store operation");
678
913
  }
679
914
  }
680
- for (const operation of puts.values()) await this.putOperation(operation);
915
+ for (const operation of puts.values()) await this.putOperation(operation, lease);
681
916
  return results;
682
917
  }, this.lockOptions);
683
918
  }
@@ -688,47 +923,45 @@ var FerricStoreStore = class extends import_langgraph2.BaseStore {
688
923
  return `${this.keyPrefix}:{lgs:${sha2562(namespaceIdentity(namespace))}}:namespace`;
689
924
  }
690
925
  itemLockKey(namespace, key) {
691
- const keyBytes = Buffer.from(key, "utf8");
692
- return `${this.keyPrefix}:{lgsi:${sha2562(Buffer.concat([
693
- namespaceIdentity(namespace),
694
- uint64(keyBytes.length),
695
- keyBytes
696
- ]))}}:mutation-lock`;
926
+ return `${this.keyPrefix}:{lgsi:${itemIdentityDigest(namespace, key)}}:mutation-lock`;
927
+ }
928
+ itemDataKey(namespace, key) {
929
+ return `${this.keyPrefix}:{lgsi:${itemIdentityDigest(namespace, key)}}:atomic-item`;
697
930
  }
698
931
  async getOperation(operation) {
699
932
  validateNamespace(operation.namespace);
700
933
  if (typeof operation.key !== "string") throw new TypeError("store key must be text");
701
- const value = await this.client.command(
702
- "HGET",
703
- this.namespaceKey(operation.namespace),
704
- itemField(operation.key)
705
- );
706
- return value == null ? null : decodeItem(value);
934
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
935
+ return snapshot.record == null ? null : itemFromRecord(snapshot.record);
707
936
  }
708
- async putOperation(operation) {
937
+ async putOperation(operation, lease) {
709
938
  validatePut(operation);
710
- const namespaceKey = this.namespaceKey(operation.namespace);
711
- const field = itemField(operation.key);
939
+ const dataKey = this.itemDataKey(operation.namespace, operation.key);
712
940
  const locator = catalogMember(operation.namespace, operation.key);
713
941
  if (operation.value == null) {
714
- await this.client.command("HDEL", namespaceKey, field);
715
- await this.client.command("ZREM", this.catalogKey(), locator);
716
- return;
942
+ for (let attempt = 0; attempt < 8; attempt += 1) {
943
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
944
+ if (snapshot.expected?.equals(DELETED_ITEM) === true) return;
945
+ if (await lease.compareAndSet(dataKey, snapshot.expected, DELETED_ITEM)) return;
946
+ }
947
+ throw new Error("concurrent FerricStore LangGraph store deletion did not converge");
717
948
  }
718
949
  const storedValue = snapshotJsonValue(operation.value);
719
- const existing = await this.client.command("HGET", namespaceKey, field);
720
- const now = (/* @__PURE__ */ new Date()).toISOString();
721
- const createdAt = existing == null ? now : decodeItemRecord(existing).createdAt;
722
- const record = {
723
- createdAt,
724
- formatVersion: FORMAT_VERSION2,
725
- key: operation.key,
726
- namespace: [...operation.namespace],
727
- updatedAt: now,
728
- value: storedValue
729
- };
730
- await this.client.command("ZADD", this.catalogKey(), 0, locator);
731
- await this.client.command("HSET", namespaceKey, field, encodeItem(record));
950
+ await lease.publish("ZADD", this.catalogKey(), 0, locator);
951
+ for (let attempt = 0; attempt < 8; attempt += 1) {
952
+ const snapshot = await this.readItemSnapshot(operation.namespace, operation.key);
953
+ const now = (/* @__PURE__ */ new Date()).toISOString();
954
+ const record = {
955
+ createdAt: snapshot.record?.createdAt ?? now,
956
+ formatVersion: FORMAT_VERSION2,
957
+ key: operation.key,
958
+ namespace: [...operation.namespace],
959
+ updatedAt: now,
960
+ value: storedValue
961
+ };
962
+ if (await lease.compareAndSet(dataKey, snapshot.expected, encodeItem(record))) return;
963
+ }
964
+ throw new Error("concurrent FerricStore LangGraph store mutation did not converge");
732
965
  }
733
966
  async searchOperation(operation) {
734
967
  validateNamespacePrefix(operation.namespacePrefix);
@@ -766,8 +999,23 @@ var FerricStoreStore = class extends import_langgraph2.BaseStore {
766
999
  return [...namespaces.values()].sort(compareNamespaces).slice(offset, offset + limit);
767
1000
  }
768
1001
  async readCatalogItem(namespace, key) {
769
- const value = await this.client.command("HGET", this.namespaceKey(namespace), itemField(key));
770
- return value == null ? null : decodeItem(value);
1002
+ const snapshot = await this.readItemSnapshot(namespace, key);
1003
+ return snapshot.record == null ? null : itemFromRecord(snapshot.record);
1004
+ }
1005
+ async readItemSnapshot(namespace, key) {
1006
+ const expected = await readAtomicValue(
1007
+ this.client,
1008
+ this.itemDataKey(namespace, key),
1009
+ "LangGraph atomic store item"
1010
+ );
1011
+ if (expected != null) {
1012
+ return {
1013
+ expected,
1014
+ record: expected.equals(DELETED_ITEM) ? null : decodeItemRecord(expected)
1015
+ };
1016
+ }
1017
+ const legacy = await this.client.command("HGET", this.namespaceKey(namespace), itemField(key));
1018
+ return { expected, record: legacy == null ? null : decodeItemRecord(legacy) };
771
1019
  }
772
1020
  async *catalogLocators() {
773
1021
  let offset = 0;
@@ -867,8 +1115,7 @@ function snapshotJsonValue(value) {
867
1115
  function encodeItem(record) {
868
1116
  return Buffer.from(JSON.stringify(record), "utf8");
869
1117
  }
870
- function decodeItem(value) {
871
- const record = decodeItemRecord(value);
1118
+ function itemFromRecord(record) {
872
1119
  return {
873
1120
  createdAt: new Date(record.createdAt),
874
1121
  key: record.key,
@@ -877,6 +1124,14 @@ function decodeItem(value) {
877
1124
  value: record.value
878
1125
  };
879
1126
  }
1127
+ function itemIdentityDigest(namespace, key) {
1128
+ const keyBytes = Buffer.from(key, "utf8");
1129
+ return sha2562(Buffer.concat([
1130
+ namespaceIdentity(namespace),
1131
+ uint64(keyBytes.length),
1132
+ keyBytes
1133
+ ]));
1134
+ }
880
1135
  function decodeItemRecord(value) {
881
1136
  if (!(typeof value === "string" || Buffer.isBuffer(value) || value instanceof Uint8Array)) {
882
1137
  throw new TypeError("FerricStore returned a non-binary LangGraph store item");
@@ -1143,13 +1398,22 @@ var LangGraphFlow = class {
1143
1398
  this.options = { ...options };
1144
1399
  }
1145
1400
  async config(flow, graphContext) {
1401
+ const invokeOptions = this.options.invokeOptions ?? {};
1146
1402
  const additional = await this.options.config?.(flow) ?? {};
1403
+ const baseConfigurable = invokeOptions.configurable ?? {};
1404
+ if (typeof baseConfigurable !== "object" || Array.isArray(baseConfigurable)) {
1405
+ throw new TypeError("LangGraph invokeOptions configurable must be an object");
1406
+ }
1147
1407
  const rawConfigurable = additional.configurable ?? {};
1148
- if (rawConfigurable == null || typeof rawConfigurable !== "object" || Array.isArray(rawConfigurable)) {
1408
+ if (typeof rawConfigurable !== "object" || Array.isArray(rawConfigurable)) {
1149
1409
  throw new TypeError("LangGraph config configurable must be an object");
1150
1410
  }
1411
+ const baseMetadata = invokeOptions.metadata ?? {};
1412
+ if (typeof baseMetadata !== "object" || Array.isArray(baseMetadata)) {
1413
+ throw new TypeError("LangGraph invokeOptions metadata must be an object");
1414
+ }
1151
1415
  const rawMetadata = additional.metadata ?? {};
1152
- if (rawMetadata == null || typeof rawMetadata !== "object" || Array.isArray(rawMetadata)) {
1416
+ if (typeof rawMetadata !== "object" || Array.isArray(rawMetadata)) {
1153
1417
  throw new TypeError("LangGraph config metadata must be an object");
1154
1418
  }
1155
1419
  const threadId = requireText(
@@ -1162,13 +1426,19 @@ var LangGraphFlow = class {
1162
1426
  "checkpointNs",
1163
1427
  true
1164
1428
  );
1165
- const context = graphContext === void 0 ? this.options.context == null ? new LangGraphFlowContext(flow, threadId, checkpointNs) : await this.options.context(flow) : graphContext;
1429
+ 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;
1166
1430
  return {
1167
- ...this.options.invokeOptions,
1431
+ ...invokeOptions,
1168
1432
  ...additional,
1169
- configurable: { ...rawConfigurable, checkpoint_ns: checkpointNs, thread_id: threadId },
1433
+ configurable: {
1434
+ ...baseConfigurable,
1435
+ ...rawConfigurable,
1436
+ checkpoint_ns: checkpointNs,
1437
+ thread_id: threadId
1438
+ },
1170
1439
  context,
1171
1440
  metadata: {
1441
+ ...baseMetadata,
1172
1442
  ...rawMetadata,
1173
1443
  ferricflow_id: flow.id,
1174
1444
  ferricflow_state: flow.logicalState,
@@ -1184,7 +1454,7 @@ var LangGraphFlow = class {
1184
1454
  if ((this.options.recoverExisting ?? true) && this.graph.getState != null) {
1185
1455
  hasCheckpoint = snapshotHasCheckpoint(await this.graph.getState(config));
1186
1456
  }
1187
- input = hasCheckpoint ? null : await (this.options.input?.(flow) ?? flow.payload);
1457
+ input = hasCheckpoint ? null : this.options.input == null ? flow.payload : await this.options.input(flow);
1188
1458
  }
1189
1459
  const value = await this.graph.invoke(input, config);
1190
1460
  const configurable = config.configurable;