@arnilo/prism-server 0.2.1 → 0.2.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.
- package/CHANGELOG.md +5 -0
- package/dist/conversations.d.ts +8 -2
- package/dist/conversations.js +67 -37
- package/package.json +3 -3
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
|
package/dist/conversations.d.ts
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
|
-
|
|
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
|
+
} | void>;
|
|
54
60
|
readonly lifecycle?: Pick<PersistenceLifecycleStore, "applyRetention">;
|
|
55
61
|
}
|
|
56
62
|
export interface ConversationSessionFactoryInput {
|
package/dist/conversations.js
CHANGED
|
@@ -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
|
|
84
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 === "
|
|
456
|
-
?
|
|
457
|
-
: error.reason === "
|
|
458
|
-
?
|
|
459
|
-
: error.reason === "
|
|
460
|
-
?
|
|
461
|
-
:
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"pack:dry-run": "npm pack --dry-run"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@arnilo/prism": "0.2.
|
|
33
|
-
"@arnilo/prism-workflows": "0.2.
|
|
32
|
+
"@arnilo/prism": "0.2.2",
|
|
33
|
+
"@arnilo/prism-workflows": "0.2.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@arnilo/prism": "file:../..",
|