@semiont/make-meaning 0.5.32 → 0.5.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -230,10 +230,23 @@ interface KnowledgeBase {
230
230
  /** The lifecycle half of the working tree (GATEWAY.md D4a): the Archivist
231
231
  * accessions, moves, removes and resolves — it never serves bytes. */
232
232
  type ContentLifecycle = Pick<WorkingTreeStore, 'register' | 'move' | 'remove' | 'resolveUri'>;
233
- /** The record's single write seam. `Stower` is the only appendEvent caller
234
- * anywhere in make-meaning or the gateway (post-#1252): single-owner by
235
- * construction. A second caller is a design smell, not a wiring chore. */
236
- type EventAppends = Pick<EventStore, 'appendEvent'>;
233
+ /**
234
+ * The record's single write seam. `Stower` is the only appendEvent caller
235
+ * anywhere in make-meaning or the gateway (post-#1252): single-owner by
236
+ * construction. A second caller is a design smell, not a wiring chore.
237
+ *
238
+ * It carries a read — `viewStorage.get`, narrowed to `get` — because one write
239
+ * path is at-least-once and must not duplicate the log
240
+ * (COMMIT-ACK-FALSE-FAILURE F3): `mark:commit` diffs its batch against what the
241
+ * resource already holds. This does NOT reverse JOB-RESTART-SAFETY HD1, which
242
+ * rejected read-before-write for a WORKER reading a REMOTE store mid-recovery
243
+ * — "the thing it would read is exactly what is down". This read is inside the
244
+ * Archivist, against the store it is about to write, and cannot be down
245
+ * relative to itself.
246
+ */
247
+ type EventAppends = Pick<EventStore, 'appendEvent'> & {
248
+ readonly viewStorage: Pick<ViewStorage, 'get'>;
249
+ };
237
250
  /** Read-only reach into the event store: the log for queries, the
238
251
  * materializer for on-demand view assembly (`assembleResourceGraph`). */
239
252
  interface EventStoreReads {
@@ -350,10 +363,22 @@ declare class Stower {
350
363
  *
351
364
  * Appends are sequential, not concurrent: the event log is the system of
352
365
  * record and a batch that half-lands under concurrency is harder to reason
353
- * about than one that stops at the first failure. A partial batch is
354
- * reported as a failure and the worker retries the WHOLE unit, which is safe
355
- * because ids are deterministic (P3) and the annotation fold is idempotent
356
- * by id re-appending what already landed changes nothing.
366
+ * about than one that stops at the first failure.
367
+ *
368
+ * This channel is AT-LEAST-ONCE, and the log must not grow on a repeat
369
+ * (COMMIT-ACK-FALSE-FAILURE F3). Two paths re-send a batch that already
370
+ * landed: an acknowledgement lost after a successful append (the unit is
371
+ * never checkpointed, so the retry re-runs exactly the unit that landed), and
372
+ * a partial batch, reported as a failure and retried whole. Deterministic ids
373
+ * (JOB-RESTART-SAFETY P3) made those safe for the PROJECTIONS — the resource
374
+ * view and the graph both refuse a duplicate id — but a projection's guard
375
+ * says nothing about the log, which appends whatever it is handed. The result
376
+ * was a green graph over a doubled log: silent, and not undoable.
377
+ *
378
+ * So the batch is diffed against what the resource already holds. ONE view
379
+ * read per commit, never per annotation: the view for a 1,673-annotation
380
+ * resource is ~3 MB, and re-reading it per append would cost gigabytes of
381
+ * parsing for a single job.
357
382
  */
358
383
  private handleMarkCommit;
359
384
  private handleMarkDelete;
@@ -1650,6 +1675,9 @@ declare class Smelter {
1650
1675
  */
1651
1676
  private classifyEmbeddable;
1652
1677
  /** Page through `browse:resources-requested` until the catalog is exhausted. */
1678
+ /** Shared with the weaver since 2026-09-09 — see `browse-resources.ts` for why the
1679
+ * retry lives at the page rather than around the pass. `archived: false` is
1680
+ * the smelter's own filter and the one difference between the two loops. */
1653
1681
  private listAllResources;
1654
1682
  }
1655
1683
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { STALL_THRESHOLD_MS, FsJobQueue } from '@semiont/jobs';
2
2
  import { FilesystemViewStorage, resolveStorageUri, EventQuery, createEventStore } from '@semiont/event-sourcing';
3
- import { getResourceEntityTypes, getResourceId, getTargetSource, resourceId, decodeRepresentation, getBodySource, getStorageUri, getPrimaryRepresentation, getTargetSelector, deriveViews, getTextPositionSelector, annotationId, errField, userId, generateUuid, getExactText, busRequest, cloneToken, assembleAnnotation, applyBodyOperations, didToAgent, isGenerationJobParams, jobId, entityType, baseUrl, busLog, BRIDGED_CHANNELS, burstBuffer, textSourceOf, chunkText, getPrimaryMediaType, yieldsGeometryOf, isAnnotatable, softwareToAgent } from '@semiont/core';
3
+ import { getResourceEntityTypes, getResourceId, getTargetSource, resourceId, decodeRepresentation, getBodySource, getStorageUri, getPrimaryRepresentation, getTargetSelector, deriveViews, getTextPositionSelector, annotationId, errField, userId, generateUuid, getExactText, busRequest, cloneToken, assembleAnnotation, applyBodyOperations, didToAgent, isGenerationJobParams, jobId, entityType, baseUrl, busLog, BRIDGED_CHANNELS, burstBuffer, textSourceOf, chunkText, getPrimaryMediaType, yieldsGeometryOf, isAnnotatable, retryWithBackoff, isPeerUnavailable, withDeadline, softwareToAgent } from '@semiont/core';
4
4
  import { recordGatherDegrade, withActorSpan, recordBusEmit, withSpan, SpanKind, registerJobQueueProvider, registerVectorIndexSizeProvider } from '@semiont/observability';
5
5
  import { createInferenceClient } from '@semiont/inference';
6
6
  import { compareByRecencyThenId, getGraphDatabase } from '@semiont/graph';
@@ -11574,36 +11574,56 @@ var Stower = class {
11574
11574
  *
11575
11575
  * Appends are sequential, not concurrent: the event log is the system of
11576
11576
  * record and a batch that half-lands under concurrency is harder to reason
11577
- * about than one that stops at the first failure. A partial batch is
11578
- * reported as a failure and the worker retries the WHOLE unit, which is safe
11579
- * because ids are deterministic (P3) and the annotation fold is idempotent
11580
- * by id re-appending what already landed changes nothing.
11577
+ * about than one that stops at the first failure.
11578
+ *
11579
+ * This channel is AT-LEAST-ONCE, and the log must not grow on a repeat
11580
+ * (COMMIT-ACK-FALSE-FAILURE F3). Two paths re-send a batch that already
11581
+ * landed: an acknowledgement lost after a successful append (the unit is
11582
+ * never checkpointed, so the retry re-runs exactly the unit that landed), and
11583
+ * a partial batch, reported as a failure and retried whole. Deterministic ids
11584
+ * (JOB-RESTART-SAFETY P3) made those safe for the PROJECTIONS — the resource
11585
+ * view and the graph both refuse a duplicate id — but a projection's guard
11586
+ * says nothing about the log, which appends whatever it is handed. The result
11587
+ * was a green graph over a doubled log: silent, and not undoable.
11588
+ *
11589
+ * So the batch is diffed against what the resource already holds. ONE view
11590
+ * read per commit, never per annotation: the view for a 1,673-annotation
11591
+ * resource is ~3 MB, and re-reading it per append would cost gigabytes of
11592
+ * parsing for a single job.
11581
11593
  */
11582
11594
  async handleMarkCommit(event) {
11583
11595
  if (!event._userId) {
11584
11596
  throw new Error("mark:commit missing _userId (gateway injection)");
11585
11597
  }
11586
11598
  const annotations = event.annotations ?? [];
11599
+ const rid = resourceId(event.resourceId);
11587
11600
  try {
11588
- let persisted = 0;
11601
+ const view = await this.stores.eventStore.viewStorage.get(rid);
11602
+ const present = new Set((view?.annotations.annotations ?? []).map((a) => String(a.id)));
11589
11603
  for (const annotation of annotations) {
11604
+ if (present.has(String(annotation.id))) continue;
11590
11605
  await this.stores.eventStore.appendEvent({
11591
11606
  type: "mark:added",
11592
- resourceId: resourceId(event.resourceId),
11607
+ resourceId: rid,
11593
11608
  userId: userId(event._userId),
11594
11609
  version: 1,
11595
11610
  payload: { annotation }
11596
11611
  });
11597
- persisted++;
11612
+ present.add(String(annotation.id));
11598
11613
  }
11599
11614
  this.logger.debug("Committed annotation batch", {
11600
11615
  correlationId: event.correlationId,
11601
11616
  resourceId: event.resourceId,
11602
- persisted
11617
+ persisted: annotations.length
11603
11618
  });
11604
11619
  this.eventBus.get("mark:commit-ok").next({
11605
11620
  correlationId: event.correlationId,
11606
- response: { persisted, annotationIds: annotations.map((a) => String(a.id)) }
11621
+ // The DURABLE count, which is what the acknowledgement means ("every
11622
+ // annotation named by the command is in the event log"). Not an append
11623
+ // tally: a retry whose annotations are all already present has
11624
+ // succeeded, and must be indistinguishable from the first commit or the
11625
+ // caller would have to interpret a 0 that means "all good".
11626
+ response: { persisted: annotations.length, annotationIds: annotations.map((a) => String(a.id)) }
11607
11627
  });
11608
11628
  } catch (error) {
11609
11629
  this.logger.error("Failed to commit annotation batch", {
@@ -11829,7 +11849,11 @@ var Stower = class {
11829
11849
  jobId: event.jobId,
11830
11850
  jobType: event.jobType,
11831
11851
  ...event.annotationId ? { annotationId: event.annotationId } : {},
11832
- result: event.result
11852
+ result: event.result,
11853
+ // How durability was ESTABLISHED (COMMIT-ACK-FALSE-FAILURE). An
11854
+ // acknowledged batch and one inferred from a probe are different
11855
+ // claims; absent means the question never arose.
11856
+ ...event.durability !== void 0 ? { durability: event.durability } : {}
11833
11857
  }
11834
11858
  });
11835
11859
  }
@@ -11846,7 +11870,19 @@ var Stower = class {
11846
11870
  jobId: event.jobId,
11847
11871
  jobType: event.jobType,
11848
11872
  ...event.annotationId ? { annotationId: event.annotationId } : {},
11849
- error: event.error
11873
+ error: event.error,
11874
+ // The worker's JUDGMENTS, not just its message. Both are computed where
11875
+ // the error is still typed and are unrecoverable here — the only other
11876
+ // witness in the log is `error`, a flattened English string. Spread
11877
+ // conditionally: absent `failureClass` means UNRECOGNISED, a different
11878
+ // claim from 'transient', and defaulting either would write a judgment
11879
+ // nobody made into a log nobody can rewrite.
11880
+ ...event.failureClass !== void 0 ? { failureClass: event.failureClass } : {},
11881
+ ...event.willRetry !== void 0 ? { willRetry: event.willRetry } : {},
11882
+ // How durability was ESTABLISHED (COMMIT-ACK-FALSE-FAILURE). An
11883
+ // acknowledged batch and one inferred from a probe are different
11884
+ // claims; absent means the question never arose.
11885
+ ...event.durability !== void 0 ? { durability: event.durability } : {}
11850
11886
  }
11851
11887
  });
11852
11888
  }
@@ -13384,43 +13420,36 @@ async function createJobQueue(state, eventBus, logger) {
13384
13420
  return { jobQueue, jobStatusSubscription };
13385
13421
  }
13386
13422
  var STARTUP_CONNECT_TIMEOUT_MS = 6e4;
13387
- async function withStartupTimeout(what, work) {
13388
- let timer;
13389
- try {
13390
- return await Promise.race([
13391
- work,
13392
- new Promise((_resolve, reject) => {
13393
- timer = setTimeout(
13394
- () => reject(
13395
- new Error(
13396
- `${what} did not become available within ${STARTUP_CONNECT_TIMEOUT_MS / 1e3}s. Exiting so the container restart policy can retry \u2014 it is normal for a dependency to be slow when every service restarts at once.`
13397
- )
13398
- ),
13399
- STARTUP_CONNECT_TIMEOUT_MS
13400
- );
13401
- })
13402
- ]);
13403
- } finally {
13404
- if (timer !== void 0) clearTimeout(timer);
13405
- }
13406
- }
13423
+ var RESTART_HINT = "Exiting so the container restart policy can retry \u2014 it is normal for a dependency to be slow when every service restarts at once.";
13407
13424
  async function connectStores(project, config, eventBus, logger, skipRebuild) {
13408
13425
  const graphConfig = config.services.graph;
13409
13426
  logger.info("Connecting to graph database", { type: graphConfig.type });
13410
- const graphDb = await withStartupTimeout("Graph database", getGraphDatabase(graphConfig));
13427
+ const graphDb = await withDeadline(
13428
+ "Graph database",
13429
+ STARTUP_CONNECT_TIMEOUT_MS,
13430
+ () => getGraphDatabase(graphConfig),
13431
+ RESTART_HINT
13432
+ );
13411
13433
  const eventStore = createEventStore(project, eventBus, logger.child({ component: "event-store" }));
13412
13434
  const vectorsConfig = config.services.vectors;
13413
13435
  const embeddingConfig = config.services.embedding;
13414
13436
  const { createVectorStore, createEmbeddingProvider } = await import('@semiont/vectors');
13415
13437
  logger.info("Connecting to embedding provider", { type: embeddingConfig.type, model: embeddingConfig.model });
13416
- const embeddingProvider = await withStartupTimeout(
13438
+ const embeddingProvider = await withDeadline(
13417
13439
  "Embedding provider",
13418
- createEmbeddingProvider(embeddingConfig)
13440
+ STARTUP_CONNECT_TIMEOUT_MS,
13441
+ () => createEmbeddingProvider(embeddingConfig),
13442
+ RESTART_HINT
13419
13443
  );
13420
13444
  logger.info("Connecting to vector store", { type: vectorsConfig.type });
13421
- const vectorStore = await withStartupTimeout(
13445
+ const vectorStore = await withDeadline(
13422
13446
  "Vector store",
13423
- createVectorStore({
13447
+ STARTUP_CONNECT_TIMEOUT_MS,
13448
+ (signal) => createVectorStore({
13449
+ // The deadline this call is already being raced against, handed down so
13450
+ // the dimension-probe retry stops when it fires instead of being abandoned
13451
+ // mid-flight. This is the whole point of the signal parameter.
13452
+ signal,
13424
13453
  type: vectorsConfig.type,
13425
13454
  host: vectorsConfig.host,
13426
13455
  port: vectorsConfig.port,
@@ -13432,7 +13461,8 @@ async function connectStores(project, config, eventBus, logger, skipRebuild) {
13432
13461
  // network round-trip a precondition of booting. A `memory` store, or a
13433
13462
  // Qdrant whose collections already exist, never consults the provider.
13434
13463
  dimensions: () => embeddingProvider.dimensions()
13435
- })
13464
+ }),
13465
+ RESTART_HINT
13436
13466
  );
13437
13467
  if (vectorsConfig.type === "memory") {
13438
13468
  logger.info("memory vector store: the index rebuilds from the event log on every restart (reconcile re-embeds)");
@@ -13789,6 +13819,28 @@ function partitionByType(events) {
13789
13819
  if (currentRun.length > 0) runs.push(currentRun);
13790
13820
  return runs;
13791
13821
  }
13822
+ var RESOURCE_LISTING_RETRY = {
13823
+ attempts: 17,
13824
+ initialDelayMs: 1e3,
13825
+ maxDelayMs: 3e4
13826
+ };
13827
+ var RESOURCES_CHANNEL = "browse:resources-requested";
13828
+ async function browseAllResources(bus, options) {
13829
+ const all = [];
13830
+ for (; ; ) {
13831
+ const page = await retryWithBackoff(
13832
+ () => busRequest(bus, RESOURCES_CHANNEL, {
13833
+ ...options.archived !== void 0 ? { archived: options.archived } : {},
13834
+ offset: all.length,
13835
+ limit: options.limit
13836
+ }),
13837
+ isPeerUnavailable,
13838
+ RESOURCE_LISTING_RETRY
13839
+ );
13840
+ all.push(...page.resources);
13841
+ if (page.resources.length === 0 || all.length >= page.total) return all;
13842
+ }
13843
+ }
13792
13844
 
13793
13845
  // src/smelter.ts
13794
13846
  function sameStringSet(a, b) {
@@ -14544,17 +14596,11 @@ var Smelter = class _Smelter {
14544
14596
  return embeddable;
14545
14597
  }
14546
14598
  /** Page through `browse:resources-requested` until the catalog is exhausted. */
14547
- async listAllResources() {
14548
- const all = [];
14549
- for (; ; ) {
14550
- const page = await busRequest(
14551
- this.bus,
14552
- "browse:resources-requested",
14553
- { archived: false, offset: all.length, limit: _Smelter.RECONCILE_PAGE_SIZE }
14554
- );
14555
- all.push(...page.resources);
14556
- if (page.resources.length === 0 || all.length >= page.total) return all;
14557
- }
14599
+ /** Shared with the weaver since 2026-09-09 — see `browse-resources.ts` for why the
14600
+ * retry lives at the page rather than around the pass. `archived: false` is
14601
+ * the smelter's own filter and the one difference between the two loops. */
14602
+ listAllResources() {
14603
+ return browseAllResources(this.bus, { limit: _Smelter.RECONCILE_PAGE_SIZE, archived: false });
14558
14604
  }
14559
14605
  };
14560
14606