@arnilo/prism-server 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2] - 2026-08-13
4
+
5
+ ### Changed
6
+ - Conversation create/branch/archive now route through the session-store appendSession version/CAS: concurrent writes surface as metadata_conflict mapped to HTTP 409 (carrying versions only, never metadata content); branch caps are enforced inside the CAS write. See docs/conversations.md.
7
+
3
8
  ## [0.1.0] - 2026-08-09
4
9
 
5
10
  ### Changed
@@ -49,8 +49,14 @@ export declare function resolveConversationLimits(input?: ConversationLimits): R
49
49
  export interface ConversationServiceStore {
50
50
  querySessions(query: import("@arnilo/prism").SessionQuery): Promise<PersistencePage<SessionRecord>>;
51
51
  queryEvents(query: import("@arnilo/prism").AgentEventQuery): Promise<PersistencePage<AgentEventRecord>>;
52
- /** Required at factory time; optional in the type so persistence unions stay assignable. */
53
- appendSession?(record: SessionRecord): Promise<void>;
52
+ /** Required at factory time; optional in the type so persistence unions stay assignable.
53
+ * Additive CAS: `expectedVersion` requires the stored version to match (0 = create-only);
54
+ * the returned `version` is the new write version. Throws `SessionMetadataConflictError`. */
55
+ appendSession?(record: SessionRecord & {
56
+ readonly expectedVersion?: number;
57
+ }): Promise<{
58
+ readonly version: number;
59
+ } | undefined>;
54
60
  readonly lifecycle?: Pick<PersistenceLifecycleStore, "applyRetention">;
55
61
  }
56
62
  export interface ConversationSessionFactoryInput {
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { assertIdentityActive, assertIdentityMatchesOwnership, CONVERSATION_METADATA_KEY, ConversationError, conversationMarkerMetadata, conversationThreadFromRecord, decodeConversationReplayCursor, encodeConversationReplayCursor, } from "@arnilo/prism";
2
+ import { assertIdentityActive, assertIdentityMatchesOwnership, CONVERSATION_METADATA_KEY, ConversationError, conversationMarkerMetadata, conversationThreadFromRecord, decodeConversationReplayCursor, encodeConversationReplayCursor, isSessionMetadataConflict, } from "@arnilo/prism";
3
3
  import { PrismServerError } from "./types.js";
4
4
  /** Phase 9 freeze: thread page 50/200; replay page 100/500; cursor 4/16 KiB; title 256 B/2 KiB;
5
5
  * request id 256 B/2 KiB; active branches 16/64; export 8/32 MiB and 100/500 pages; body 64 KiB/1 MiB. */
@@ -58,7 +58,7 @@ export function createConversationService(store, options) {
58
58
  }
59
59
  async function writeMarker(thread, marker) {
60
60
  const now = new Date().toISOString();
61
- await appendSession({
61
+ const result = await appendSession({
62
62
  id: thread.id,
63
63
  ...(thread.tenantId !== undefined ? { tenantId: thread.tenantId } : {}),
64
64
  ...(thread.accountId !== undefined ? { accountId: thread.accountId } : {}),
@@ -66,7 +66,13 @@ export function createConversationService(store, options) {
66
66
  createdAt: thread.createdAt,
67
67
  updatedAt: now,
68
68
  metadata: conversationMarkerMetadata(marker),
69
+ expectedVersion: thread.version ?? 0,
69
70
  });
71
+ return result?.version ?? (thread.version ?? 0) + 1;
72
+ }
73
+ /** CAS conflict on create is a race between two get-or-create callers: the winner wins. */
74
+ function throwMetadataConflict() {
75
+ throw new ConversationError("Conversation thread changed concurrently", "metadata_conflict");
70
76
  }
71
77
  return {
72
78
  async create(input) {
@@ -80,8 +86,8 @@ export function createConversationService(store, options) {
80
86
  if (input.requestId !== undefined)
81
87
  assertBytes(input.requestId, limits.requestIdBytes, "request_id_too_large");
82
88
  if (input.id !== undefined) {
83
- // Idempotent get-or-create for explicit ids. ponytail: concurrent creates race to the
84
- // last metadata write; both callers receive a thread and ownership is enforced on read.
89
+ // Idempotent get-or-create for explicit ids; the create-only CAS below is the
90
+ // race backstop, so a concurrent create returns the winner's thread untouched.
85
91
  const existing = await this.get({ ...input, threadId: id }).catch((error) => {
86
92
  if (error instanceof ConversationError && error.reason === "not_found")
87
93
  return undefined;
@@ -91,18 +97,26 @@ export function createConversationService(store, options) {
91
97
  return existing;
92
98
  }
93
99
  const now = new Date().toISOString();
94
- await appendSession({
95
- id,
96
- ...input.ownership,
97
- createdAt: now,
98
- updatedAt: now,
99
- metadata: conversationMarkerMetadata({
100
- ...(input.title === undefined ? {} : { title: input.title }),
101
- state: "active",
102
- ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
103
- ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
104
- }),
105
- });
100
+ try {
101
+ await appendSession({
102
+ id,
103
+ ...input.ownership,
104
+ createdAt: now,
105
+ updatedAt: now,
106
+ metadata: conversationMarkerMetadata({
107
+ ...(input.title === undefined ? {} : { title: input.title }),
108
+ state: "active",
109
+ ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
110
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
111
+ }),
112
+ expectedVersion: 0,
113
+ });
114
+ }
115
+ catch (error) {
116
+ if (isSessionMetadataConflict(error))
117
+ return this.get({ ...input, threadId: id });
118
+ throw error;
119
+ }
106
120
  return this.get({ ...input, threadId: id });
107
121
  },
108
122
  async list(input) {
@@ -169,26 +183,40 @@ export function createConversationService(store, options) {
169
183
  if (thread.branches.length >= limits.maxActiveBranches) {
170
184
  throw new ConversationError("Too many active branches for this thread", "too_many_branches");
171
185
  }
172
- // ponytail: read-modify-write of branch refs; concurrent branch calls can lose a ref, so
173
- // the cap is approximate and the entry tree remains the content source of truth.
174
- await writeMarker(thread, {
175
- ...(thread.title === undefined ? {} : { title: thread.title }),
176
- state: thread.state,
177
- branches: [...thread.branches, { leafId, createdAt: new Date().toISOString() }],
178
- ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
179
- });
186
+ // Branch refs are append-only within the marker; the CAS version guard makes the
187
+ // read-modify-write atomic, so concurrent branches cannot lose a ref or exceed the cap.
188
+ try {
189
+ await writeMarker(thread, {
190
+ ...(thread.title === undefined ? {} : { title: thread.title }),
191
+ state: thread.state,
192
+ branches: [...thread.branches, { leafId, createdAt: new Date().toISOString() }],
193
+ ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
194
+ });
195
+ }
196
+ catch (error) {
197
+ if (isSessionMetadataConflict(error))
198
+ throwMetadataConflict();
199
+ throw error;
200
+ }
180
201
  return loadThread(input, thread.id);
181
202
  },
182
203
  async archive(input) {
183
204
  const thread = await loadThread(input, input.threadId);
184
205
  if (thread.state === "archived")
185
206
  return thread;
186
- await writeMarker(thread, {
187
- ...(thread.title === undefined ? {} : { title: thread.title }),
188
- state: "archived",
189
- ...(thread.branches.length === 0 ? {} : { branches: thread.branches }),
190
- ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
191
- });
207
+ try {
208
+ await writeMarker(thread, {
209
+ ...(thread.title === undefined ? {} : { title: thread.title }),
210
+ state: "archived",
211
+ ...(thread.branches.length === 0 ? {} : { branches: thread.branches }),
212
+ ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
213
+ });
214
+ }
215
+ catch (error) {
216
+ if (isSessionMetadataConflict(error))
217
+ throwMetadataConflict();
218
+ throw error;
219
+ }
192
220
  return loadThread(input, thread.id);
193
221
  },
194
222
  async export(input) {
@@ -452,13 +480,15 @@ function conversationErrorResponse(error) {
452
480
  ? 404
453
481
  : error.reason === "thread_archived"
454
482
  ? 409
455
- : error.reason === "ownership"
456
- ? 403
457
- : error.reason === "unsupported"
458
- ? 501
459
- : error.reason === "not_redacted" || error.reason === "limit_exceeded"
460
- ? 500
461
- : 400;
483
+ : error.reason === "metadata_conflict"
484
+ ? 409
485
+ : error.reason === "ownership"
486
+ ? 403
487
+ : error.reason === "unsupported"
488
+ ? 501
489
+ : error.reason === "not_redacted" || error.reason === "limit_exceeded"
490
+ ? 500
491
+ : 400;
462
492
  }
463
493
  else if (error instanceof RangeError) {
464
494
  status = 400;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-server",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Optional framework-free Web Request-to-Response handler for explicitly selected Prism agents and workflows.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,14 +23,14 @@
23
23
  "CHANGELOG.md"
24
24
  ],
25
25
  "scripts": {
26
- "build": "tsc -p tsconfig.json",
26
+ "build": "node ../../scripts/with-build-lock.mjs tsc -p tsconfig.json",
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
28
- "test": "node --test dist/__tests__/*.test.js",
28
+ "test": "node ../../scripts/with-build-lock.mjs node --test dist/__tests__/*.test.js",
29
29
  "pack:dry-run": "npm pack --dry-run"
30
30
  },
31
31
  "peerDependencies": {
32
- "@arnilo/prism": "0.2.1",
33
- "@arnilo/prism-workflows": "0.2.1"
32
+ "@arnilo/prism": "0.2.3",
33
+ "@arnilo/prism-workflows": "0.2.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@arnilo/prism": "file:../..",