@echovisionlab/geul-event 0.1.0

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.
@@ -0,0 +1,287 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import {
3
+ AIDocumentCreateTranslationOperationSchema,
4
+ AIDocumentDeleteTranslationOperationSchema,
5
+ AIDocumentDeleteBlockOperationSchema,
6
+ AIDocumentDomain,
7
+ AIDocumentLocaleSchema,
8
+ AIDocumentOperationSchema,
9
+ AIDocumentReferenceSchema,
10
+ } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
11
+ import {
12
+ InteractiveAIDocumentMutationOrigin,
13
+ RelayInteractiveAIDocumentMutationRequestSchema,
14
+ } from "@echovisionlab/geul-proto/intra/collaboration_pb.ts";
15
+ import { describe, expect, it } from "vitest";
16
+ import { assertRelayInteractiveAIDocumentMutationRequest } from "./interactive-mutation.ts";
17
+
18
+ function validEvent() {
19
+ return create(RelayInteractiveAIDocumentMutationRequestSchema, {
20
+ mutationId: "mutation-1",
21
+ origin:
22
+ InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_MCP,
23
+ document: create(AIDocumentReferenceSchema, {
24
+ domain: AIDocumentDomain.AI_DOCUMENT_DOMAIN_POST,
25
+ reference: "post-1",
26
+ }),
27
+ locale: create(AIDocumentLocaleSchema, { code: "ko" }),
28
+ expectedDocumentRevision: "revision-before",
29
+ acceptedDocumentRevision: "revision-after",
30
+ operations: [
31
+ create(AIDocumentOperationSchema, {
32
+ operation: {
33
+ case: "deleteBlock",
34
+ value: create(AIDocumentDeleteBlockOperationSchema, {
35
+ blockHandle: "block-1",
36
+ }),
37
+ },
38
+ }),
39
+ ],
40
+ actorMemberId: "member-1",
41
+ });
42
+ }
43
+
44
+ describe("interactive AI document mutation relay", () => {
45
+ it.each([
46
+ InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_IN_EDITOR_AI,
47
+ InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_MCP,
48
+ ])("accepts active interactive origin %s", (origin) => {
49
+ const event = validEvent();
50
+ event.origin = origin;
51
+ expect(() =>
52
+ assertRelayInteractiveAIDocumentMutationRequest(event),
53
+ ).not.toThrow();
54
+ });
55
+
56
+ it.each([
57
+ {
58
+ name: "blank mutation id",
59
+ mutate: (event: ReturnType<typeof validEvent>) => {
60
+ event.mutationId = " ";
61
+ },
62
+ error: "id",
63
+ },
64
+ {
65
+ name: "unspecified origin",
66
+ mutate: (event: ReturnType<typeof validEvent>) => {
67
+ event.origin =
68
+ InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_UNSPECIFIED;
69
+ },
70
+ error: "origin",
71
+ },
72
+ {
73
+ name: "missing document",
74
+ mutate: (event: ReturnType<typeof validEvent>) => {
75
+ event.document = undefined;
76
+ },
77
+ error: "document",
78
+ },
79
+ {
80
+ name: "unknown origin",
81
+ mutate: (event: ReturnType<typeof validEvent>) => {
82
+ // @ts-expect-error Simulate an unknown numeric enum decoded from the wire.
83
+ event.origin = 99;
84
+ },
85
+ error: "origin",
86
+ },
87
+ {
88
+ name: "unspecified domain",
89
+ mutate: (event: ReturnType<typeof validEvent>) => {
90
+ if (event.document === undefined) {
91
+ throw new Error("test fixture document is required");
92
+ }
93
+ event.document.domain = AIDocumentDomain.AI_DOCUMENT_DOMAIN_UNSPECIFIED;
94
+ },
95
+ error: "domain",
96
+ },
97
+ {
98
+ name: "unknown domain",
99
+ mutate: (event: ReturnType<typeof validEvent>) => {
100
+ if (event.document === undefined) {
101
+ throw new Error("test fixture document is required");
102
+ }
103
+ // @ts-expect-error Simulate an unknown numeric enum decoded from the wire.
104
+ event.document.domain = 99;
105
+ },
106
+ error: "domain",
107
+ },
108
+ {
109
+ name: "blank reference",
110
+ mutate: (event: ReturnType<typeof validEvent>) => {
111
+ if (event.document === undefined) {
112
+ throw new Error("test fixture document is required");
113
+ }
114
+ event.document.reference = " ";
115
+ },
116
+ error: "reference",
117
+ },
118
+ {
119
+ name: "missing locale",
120
+ mutate: (event: ReturnType<typeof validEvent>) => {
121
+ event.locale = undefined;
122
+ },
123
+ error: "locale",
124
+ },
125
+ {
126
+ name: "blank locale",
127
+ mutate: (event: ReturnType<typeof validEvent>) => {
128
+ if (event.locale === undefined) {
129
+ throw new Error("test fixture locale is required");
130
+ }
131
+ event.locale.code = " ";
132
+ },
133
+ error: "locale",
134
+ },
135
+ {
136
+ name: "blank expected document revision",
137
+ mutate: (event: ReturnType<typeof validEvent>) => {
138
+ event.expectedDocumentRevision = " ";
139
+ },
140
+ error: "expected document revision",
141
+ },
142
+ {
143
+ name: "blank accepted document revision",
144
+ mutate: (event: ReturnType<typeof validEvent>) => {
145
+ event.acceptedDocumentRevision = " ";
146
+ },
147
+ error: "accepted document revision",
148
+ },
149
+ {
150
+ name: "blank expected target revision",
151
+ mutate: (event: ReturnType<typeof validEvent>) => {
152
+ event.expectedTargetRevision = " ";
153
+ },
154
+ error: "expected target revision",
155
+ },
156
+ {
157
+ name: "blank accepted target revision",
158
+ mutate: (event: ReturnType<typeof validEvent>) => {
159
+ event.acceptedTargetRevision = " ";
160
+ },
161
+ error: "accepted target revision",
162
+ },
163
+ {
164
+ name: "target revision disappears outside delete",
165
+ mutate: (event: ReturnType<typeof validEvent>) => {
166
+ event.expectedTargetRevision = "target-before";
167
+ event.acceptedDocumentRevision = event.expectedDocumentRevision;
168
+ },
169
+ error: "cannot disappear",
170
+ },
171
+ {
172
+ name: "source mutation preserves document revision",
173
+ mutate: (event: ReturnType<typeof validEvent>) => {
174
+ event.acceptedDocumentRevision = event.expectedDocumentRevision;
175
+ },
176
+ error: "must advance",
177
+ },
178
+ {
179
+ name: "missing operations",
180
+ mutate: (event: ReturnType<typeof validEvent>) => {
181
+ event.operations = [];
182
+ },
183
+ error: "operations",
184
+ },
185
+ {
186
+ name: "missing operation arm",
187
+ mutate: (event: ReturnType<typeof validEvent>) => {
188
+ event.operations = [create(AIDocumentOperationSchema)];
189
+ },
190
+ error: "operation 0",
191
+ },
192
+ {
193
+ name: "mixed locale lifecycle operation",
194
+ mutate: (event: ReturnType<typeof validEvent>) => {
195
+ event.operations.push(
196
+ create(AIDocumentOperationSchema, {
197
+ operation: {
198
+ case: "createTranslation",
199
+ value: create(AIDocumentCreateTranslationOperationSchema),
200
+ },
201
+ }),
202
+ );
203
+ },
204
+ error: "must be exclusive",
205
+ },
206
+ {
207
+ name: "blank actor member id",
208
+ mutate: (event: ReturnType<typeof validEvent>) => {
209
+ event.actorMemberId = " ";
210
+ },
211
+ error: "actor member id",
212
+ },
213
+ ])("rejects $name", ({ mutate, error }) => {
214
+ const event = validEvent();
215
+ mutate(event);
216
+ expect(() =>
217
+ assertRelayInteractiveAIDocumentMutationRequest(event),
218
+ ).toThrow(error);
219
+ });
220
+
221
+ it("accepts existing-target and missing-target value transitions", () => {
222
+ const existing = validEvent();
223
+ existing.acceptedDocumentRevision = existing.expectedDocumentRevision;
224
+ existing.expectedTargetRevision = "target-before";
225
+ existing.acceptedTargetRevision = "target-after";
226
+ expect(() =>
227
+ assertRelayInteractiveAIDocumentMutationRequest(existing),
228
+ ).not.toThrow();
229
+
230
+ const firstWrite = validEvent();
231
+ firstWrite.acceptedDocumentRevision = firstWrite.expectedDocumentRevision;
232
+ firstWrite.acceptedTargetRevision = "target-created";
233
+ expect(() =>
234
+ assertRelayInteractiveAIDocumentMutationRequest(firstWrite),
235
+ ).not.toThrow();
236
+ });
237
+
238
+ it("accepts a source mutation with an advanced document revision", () => {
239
+ const event = validEvent();
240
+ expect(() =>
241
+ assertRelayInteractiveAIDocumentMutationRequest(event),
242
+ ).not.toThrow();
243
+ });
244
+
245
+ it("accepts explicit target create and delete transitions", () => {
246
+ const createTarget = validEvent();
247
+ createTarget.acceptedDocumentRevision =
248
+ createTarget.expectedDocumentRevision;
249
+ createTarget.acceptedTargetRevision = "target-created";
250
+ createTarget.operations = [
251
+ create(AIDocumentOperationSchema, {
252
+ operation: {
253
+ case: "createTranslation",
254
+ value: create(AIDocumentCreateTranslationOperationSchema),
255
+ },
256
+ }),
257
+ ];
258
+ expect(() =>
259
+ assertRelayInteractiveAIDocumentMutationRequest(createTarget),
260
+ ).not.toThrow();
261
+
262
+ const deleteTarget = validEvent();
263
+ deleteTarget.acceptedDocumentRevision =
264
+ deleteTarget.expectedDocumentRevision;
265
+ deleteTarget.expectedTargetRevision = "target-before";
266
+ deleteTarget.operations = [
267
+ create(AIDocumentOperationSchema, {
268
+ operation: {
269
+ case: "deleteTranslation",
270
+ value: create(AIDocumentDeleteTranslationOperationSchema),
271
+ },
272
+ }),
273
+ ];
274
+ expect(() =>
275
+ assertRelayInteractiveAIDocumentMutationRequest(deleteTarget),
276
+ ).not.toThrow();
277
+ });
278
+
279
+ it("rejects target changes that replace the shared document token", () => {
280
+ const event = validEvent();
281
+ event.expectedTargetRevision = "target-before";
282
+ event.acceptedTargetRevision = "target-after";
283
+ expect(() =>
284
+ assertRelayInteractiveAIDocumentMutationRequest(event),
285
+ ).toThrow("preserve the document revision");
286
+ });
287
+ });
@@ -0,0 +1,136 @@
1
+ import { AIDocumentDomain } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
2
+ import {
3
+ InteractiveAIDocumentMutationOrigin,
4
+ type RelayInteractiveAIDocumentMutationRequest,
5
+ } from "@echovisionlab/geul-proto/intra/collaboration_pb.ts";
6
+
7
+ const activeDocumentDomains = new Set<number>(
8
+ Object.values(AIDocumentDomain).filter(
9
+ (value): value is number =>
10
+ typeof value === "number" &&
11
+ value !== AIDocumentDomain.AI_DOCUMENT_DOMAIN_UNSPECIFIED,
12
+ ),
13
+ );
14
+
15
+ function requireNonBlank(value: string, name: string): void {
16
+ if (value.trim() === "") {
17
+ throw new Error(`interactive AI document mutation ${name} is required`);
18
+ }
19
+ }
20
+
21
+ // Transport-independent validation shared by the post-commit API publisher and
22
+ // the Collab relay receiver. It creates no retry, history, or persistence.
23
+ export function assertRelayInteractiveAIDocumentMutationRequest(
24
+ event: RelayInteractiveAIDocumentMutationRequest,
25
+ ): void {
26
+ requireNonBlank(event.mutationId, "id");
27
+
28
+ switch (event.origin) {
29
+ case InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_IN_EDITOR_AI:
30
+ case InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_MCP:
31
+ break;
32
+ case InteractiveAIDocumentMutationOrigin.INTERACTIVE_AI_DOCUMENT_MUTATION_ORIGIN_UNSPECIFIED:
33
+ default:
34
+ throw new Error(
35
+ `unsupported interactive AI document mutation origin: ${event.origin}`,
36
+ );
37
+ }
38
+
39
+ if (event.document === undefined) {
40
+ throw new Error("interactive AI document mutation document is required");
41
+ }
42
+ if (!activeDocumentDomains.has(event.document.domain)) {
43
+ throw new Error(
44
+ `unsupported interactive AI document mutation domain: ${event.document.domain}`,
45
+ );
46
+ }
47
+ requireNonBlank(event.document.reference, "reference");
48
+
49
+ if (event.locale === undefined) {
50
+ throw new Error("interactive AI document mutation locale is required");
51
+ }
52
+ requireNonBlank(event.locale.code, "locale");
53
+ requireNonBlank(event.expectedDocumentRevision, "expected document revision");
54
+ requireNonBlank(event.acceptedDocumentRevision, "accepted document revision");
55
+
56
+ if (event.operations.length === 0) {
57
+ throw new Error("interactive AI document mutation operations are required");
58
+ }
59
+ event.operations.forEach((operation, index) => {
60
+ if (operation.operation.case === undefined) {
61
+ throw new Error(
62
+ `interactive AI document mutation operation ${index} is required`,
63
+ );
64
+ }
65
+ });
66
+ if (
67
+ event.operations.length !== 1 &&
68
+ event.operations.some(
69
+ (operation) =>
70
+ operation.operation.case === "createTranslation" ||
71
+ operation.operation.case === "deleteTranslation",
72
+ )
73
+ ) {
74
+ throw new Error(
75
+ "interactive AI document mutation locale lifecycle operation must be exclusive",
76
+ );
77
+ }
78
+
79
+ if (event.expectedTargetRevision !== undefined) {
80
+ requireNonBlank(event.expectedTargetRevision, "expected target revision");
81
+ }
82
+ if (event.acceptedTargetRevision !== undefined) {
83
+ requireNonBlank(event.acceptedTargetRevision, "accepted target revision");
84
+ }
85
+ const isTargetMutation =
86
+ event.expectedTargetRevision !== undefined ||
87
+ event.acceptedTargetRevision !== undefined;
88
+ if (isTargetMutation) {
89
+ if (event.acceptedDocumentRevision !== event.expectedDocumentRevision) {
90
+ throw new Error(
91
+ "interactive AI document target mutation must preserve the document revision",
92
+ );
93
+ }
94
+ } else {
95
+ if (
96
+ event.expectedTargetRevision === undefined &&
97
+ event.acceptedTargetRevision === undefined &&
98
+ event.acceptedDocumentRevision === event.expectedDocumentRevision
99
+ ) {
100
+ throw new Error(
101
+ "interactive AI document source mutation must advance the document revision",
102
+ );
103
+ }
104
+ }
105
+
106
+ const onlyOperation =
107
+ event.operations.length === 1 ? event.operations[0] : undefined;
108
+ if (onlyOperation?.operation.case === "createTranslation") {
109
+ if (
110
+ event.expectedTargetRevision !== undefined ||
111
+ event.acceptedTargetRevision === undefined
112
+ ) {
113
+ throw new Error(
114
+ "interactive AI document mutation create translation revisions are invalid",
115
+ );
116
+ }
117
+ } else if (onlyOperation?.operation.case === "deleteTranslation") {
118
+ if (
119
+ event.expectedTargetRevision === undefined ||
120
+ event.acceptedTargetRevision !== undefined
121
+ ) {
122
+ throw new Error(
123
+ "interactive AI document mutation delete translation revisions are invalid",
124
+ );
125
+ }
126
+ } else if (
127
+ event.expectedTargetRevision !== undefined &&
128
+ event.acceptedTargetRevision === undefined
129
+ ) {
130
+ throw new Error(
131
+ "interactive AI document mutation target revision cannot disappear",
132
+ );
133
+ }
134
+
135
+ requireNonBlank(event.actorMemberId, "actor member id");
136
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createPgmqEnvelope,
4
+ pgmqEnvelopePayload,
5
+ PGMQ_ENVELOPE_SCHEMA_VERSION,
6
+ PgmqClient,
7
+ type PgmqExecutor,
8
+ } from "./pgmq.ts";
9
+
10
+ describe("PGMQ transport contract", () => {
11
+ it("preserves protobuf bytes without ProtoJSON conversion", () => {
12
+ const payload = Uint8Array.from([0, 255, 1, 128, 127]);
13
+ const envelope = createPgmqEnvelope({
14
+ messageId: "command-1",
15
+ messageType: "api.manage.v1.Command",
16
+ payload,
17
+ createdAt: new Date("2026-08-14T00:00:00.000Z"),
18
+ });
19
+ expect(envelope.schema_version).toBe(PGMQ_ENVELOPE_SCHEMA_VERSION);
20
+ expect(pgmqEnvelopePayload(envelope)).toEqual(payload);
21
+ });
22
+
23
+ it("uses caller-owned executors for atomic enqueue", async () => {
24
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
25
+ const transaction: PgmqExecutor = {
26
+ query: (async (text: string, values?: readonly unknown[]) => {
27
+ calls.push({ text, values });
28
+ return { rows: [{ msg_id: "42" }] };
29
+ }) as PgmqExecutor["query"],
30
+ };
31
+ const envelope = createPgmqEnvelope({
32
+ messageId: "command-1",
33
+ messageType: "type",
34
+ payload: new Uint8Array(),
35
+ });
36
+ await expect(
37
+ new PgmqClient().enqueue(transaction, "work.queue", envelope),
38
+ ).resolves.toBe(42n);
39
+ expect(calls).toHaveLength(1);
40
+ expect(calls[0]?.values?.[0]).toBe("work.queue");
41
+ });
42
+
43
+ it("returns malformed rows with transport identity so consumers can archive them", async () => {
44
+ const executor: PgmqExecutor = {
45
+ query: (async () => ({
46
+ rows: [
47
+ {
48
+ msg_id: "41",
49
+ read_ct: 1,
50
+ enqueued_at: new Date("2026-08-14T00:00:00.000Z"),
51
+ vt: new Date("2026-08-14T00:00:30.000Z"),
52
+ message: { schema_version: 999 },
53
+ headers: {},
54
+ },
55
+ {
56
+ msg_id: "42",
57
+ read_ct: 1,
58
+ enqueued_at: new Date("2026-08-14T00:00:00.000Z"),
59
+ vt: new Date("2026-08-14T00:00:30.000Z"),
60
+ message: createPgmqEnvelope({
61
+ messageId: "command-2",
62
+ messageType: "type",
63
+ payload: new Uint8Array(),
64
+ }),
65
+ headers: {},
66
+ },
67
+ ],
68
+ })) as PgmqExecutor["query"],
69
+ };
70
+
71
+ const messages = await new PgmqClient().read(executor, "work.queue", {
72
+ visibilityTimeoutSeconds: 30,
73
+ batch: 2,
74
+ });
75
+ expect(messages).toHaveLength(2);
76
+ expect(messages[0]).toMatchObject({ transportId: 41n });
77
+ expect(messages[0]?.contractError).toContain("message_id");
78
+ expect(messages[1]?.envelope?.message_id).toBe("command-2");
79
+ });
80
+ });