@x12i/memorix-service 3.12.4 → 3.14.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.
Files changed (171) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/host.d.ts +2 -2
  3. package/dist/analytics/host.d.ts.map +1 -1
  4. package/dist/analytics/host.js.map +1 -1
  5. package/dist/analytics/read-row.d.ts +33 -0
  6. package/dist/analytics/read-row.d.ts.map +1 -0
  7. package/dist/analytics/read-row.js +76 -0
  8. package/dist/analytics/read-row.js.map +1 -0
  9. package/dist/analytics/tracker.d.ts +14 -0
  10. package/dist/analytics/tracker.d.ts.map +1 -1
  11. package/dist/analytics/tracker.js +55 -17
  12. package/dist/analytics/tracker.js.map +1 -1
  13. package/dist/anomaly/catalog.d.ts +4 -0
  14. package/dist/anomaly/catalog.d.ts.map +1 -0
  15. package/dist/anomaly/catalog.js +315 -0
  16. package/dist/anomaly/catalog.js.map +1 -0
  17. package/dist/anomaly/descriptors.d.ts +11 -0
  18. package/dist/anomaly/descriptors.d.ts.map +1 -0
  19. package/dist/anomaly/descriptors.js +113 -0
  20. package/dist/anomaly/descriptors.js.map +1 -0
  21. package/dist/anomaly/index.d.ts +6 -0
  22. package/dist/anomaly/index.d.ts.map +1 -0
  23. package/dist/anomaly/index.js +5 -0
  24. package/dist/anomaly/index.js.map +1 -0
  25. package/dist/anomaly/records.d.ts +28 -0
  26. package/dist/anomaly/records.d.ts.map +1 -0
  27. package/dist/anomaly/records.js +384 -0
  28. package/dist/anomaly/records.js.map +1 -0
  29. package/dist/anomaly/types.d.ts +116 -0
  30. package/dist/anomaly/types.d.ts.map +1 -0
  31. package/dist/anomaly/types.js +24 -0
  32. package/dist/anomaly/types.js.map +1 -0
  33. package/dist/anomaly/values.d.ts +31 -0
  34. package/dist/anomaly/values.d.ts.map +1 -0
  35. package/dist/anomaly/values.js +171 -0
  36. package/dist/anomaly/values.js.map +1 -0
  37. package/dist/app.d.ts.map +1 -1
  38. package/dist/app.js +12 -0
  39. package/dist/app.js.map +1 -1
  40. package/dist/catalog-bridge.d.ts +21 -3
  41. package/dist/catalog-bridge.d.ts.map +1 -1
  42. package/dist/catalog-bridge.js +148 -34
  43. package/dist/catalog-bridge.js.map +1 -1
  44. package/dist/errors.d.ts +5 -0
  45. package/dist/errors.d.ts.map +1 -1
  46. package/dist/errors.js +18 -0
  47. package/dist/errors.js.map +1 -1
  48. package/dist/knowledge-upstream.d.ts +32 -0
  49. package/dist/knowledge-upstream.d.ts.map +1 -1
  50. package/dist/knowledge-upstream.js +86 -2
  51. package/dist/knowledge-upstream.js.map +1 -1
  52. package/dist/mcp/nexum-tools.d.ts +1 -1
  53. package/dist/mcp/nexum-tools.d.ts.map +1 -1
  54. package/dist/mcp/nexum-tools.js +39 -22
  55. package/dist/mcp/nexum-tools.js.map +1 -1
  56. package/dist/metadata-view.d.ts +30 -0
  57. package/dist/metadata-view.d.ts.map +1 -0
  58. package/dist/metadata-view.js +67 -0
  59. package/dist/metadata-view.js.map +1 -0
  60. package/dist/nexum-analysis/enqueue.d.ts +1 -1
  61. package/dist/nexum-analysis/enqueue.d.ts.map +1 -1
  62. package/dist/nexum-analysis/enqueue.js +4 -2
  63. package/dist/nexum-analysis/enqueue.js.map +1 -1
  64. package/dist/nexum-analysis/knowledge-read.d.ts +45 -0
  65. package/dist/nexum-analysis/knowledge-read.d.ts.map +1 -0
  66. package/dist/nexum-analysis/knowledge-read.js +26 -0
  67. package/dist/nexum-analysis/knowledge-read.js.map +1 -0
  68. package/dist/openapi/openapi.json +18458 -17718
  69. package/dist/patches/access.d.ts +15 -0
  70. package/dist/patches/access.d.ts.map +1 -0
  71. package/dist/patches/access.js +27 -0
  72. package/dist/patches/access.js.map +1 -0
  73. package/dist/patches/engine.d.ts +67 -0
  74. package/dist/patches/engine.d.ts.map +1 -0
  75. package/dist/patches/engine.js +966 -0
  76. package/dist/patches/engine.js.map +1 -0
  77. package/dist/patches/guards.d.ts +23 -0
  78. package/dist/patches/guards.d.ts.map +1 -0
  79. package/dist/patches/guards.js +77 -0
  80. package/dist/patches/guards.js.map +1 -0
  81. package/dist/patches/legacy-rows.d.ts +56 -0
  82. package/dist/patches/legacy-rows.d.ts.map +1 -0
  83. package/dist/patches/legacy-rows.js +162 -0
  84. package/dist/patches/legacy-rows.js.map +1 -0
  85. package/dist/patches/ops.d.ts +24 -0
  86. package/dist/patches/ops.d.ts.map +1 -0
  87. package/dist/patches/ops.js +204 -0
  88. package/dist/patches/ops.js.map +1 -0
  89. package/dist/patches/parse.d.ts +5 -0
  90. package/dist/patches/parse.d.ts.map +1 -0
  91. package/dist/patches/parse.js +323 -0
  92. package/dist/patches/parse.js.map +1 -0
  93. package/dist/patches/paths.d.ts +38 -0
  94. package/dist/patches/paths.d.ts.map +1 -0
  95. package/dist/patches/paths.js +204 -0
  96. package/dist/patches/paths.js.map +1 -0
  97. package/dist/patches/service.d.ts +16 -0
  98. package/dist/patches/service.d.ts.map +1 -0
  99. package/dist/patches/service.js +128 -0
  100. package/dist/patches/service.js.map +1 -0
  101. package/dist/patches/store.d.ts +87 -0
  102. package/dist/patches/store.d.ts.map +1 -0
  103. package/dist/patches/store.js +27 -0
  104. package/dist/patches/store.js.map +1 -0
  105. package/dist/patches/types.d.ts +112 -0
  106. package/dist/patches/types.d.ts.map +1 -0
  107. package/dist/patches/types.js +5 -0
  108. package/dist/patches/types.js.map +1 -0
  109. package/dist/platform.d.ts +15 -0
  110. package/dist/platform.d.ts.map +1 -1
  111. package/dist/platform.js +141 -8
  112. package/dist/platform.js.map +1 -1
  113. package/dist/routes/anomaly.d.ts +5 -0
  114. package/dist/routes/anomaly.d.ts.map +1 -0
  115. package/dist/routes/anomaly.js +308 -0
  116. package/dist/routes/anomaly.js.map +1 -0
  117. package/dist/routes/data.d.ts.map +1 -1
  118. package/dist/routes/data.js +297 -198
  119. package/dist/routes/data.js.map +1 -1
  120. package/dist/routes/knowledge.d.ts.map +1 -1
  121. package/dist/routes/knowledge.js +6 -22
  122. package/dist/routes/knowledge.js.map +1 -1
  123. package/dist/routes/metadata.d.ts.map +1 -1
  124. package/dist/routes/metadata.js +28 -1
  125. package/dist/routes/metadata.js.map +1 -1
  126. package/dist/routes/patches.d.ts +7 -0
  127. package/dist/routes/patches.d.ts.map +1 -0
  128. package/dist/routes/patches.js +111 -0
  129. package/dist/routes/patches.js.map +1 -0
  130. package/dist/routes/subject-roots-backfill.d.ts +4 -0
  131. package/dist/routes/subject-roots-backfill.d.ts.map +1 -0
  132. package/dist/routes/subject-roots-backfill.js +15 -0
  133. package/dist/routes/subject-roots-backfill.js.map +1 -0
  134. package/dist/single-read-discriminator.d.ts +17 -0
  135. package/dist/single-read-discriminator.d.ts.map +1 -0
  136. package/dist/single-read-discriminator.js +67 -0
  137. package/dist/single-read-discriminator.js.map +1 -0
  138. package/dist/subject-roots-backfill.d.ts +24 -0
  139. package/dist/subject-roots-backfill.d.ts.map +1 -0
  140. package/dist/subject-roots-backfill.js +133 -0
  141. package/dist/subject-roots-backfill.js.map +1 -0
  142. package/dist/write-precondition.d.ts +5 -1
  143. package/dist/write-precondition.d.ts.map +1 -1
  144. package/dist/write-precondition.js +11 -3
  145. package/dist/write-precondition.js.map +1 -1
  146. package/dist/writes/content-write.d.ts +207 -0
  147. package/dist/writes/content-write.d.ts.map +1 -0
  148. package/dist/writes/content-write.js +629 -0
  149. package/dist/writes/content-write.js.map +1 -0
  150. package/dist/writes/effects.d.ts +13 -0
  151. package/dist/writes/effects.d.ts.map +1 -0
  152. package/dist/writes/effects.js +28 -0
  153. package/dist/writes/effects.js.map +1 -0
  154. package/dist/writes/error-shape.d.ts +11 -0
  155. package/dist/writes/error-shape.d.ts.map +1 -0
  156. package/dist/writes/error-shape.js +107 -0
  157. package/dist/writes/error-shape.js.map +1 -0
  158. package/dist/writes/queued-write.d.ts +38 -0
  159. package/dist/writes/queued-write.d.ts.map +1 -0
  160. package/dist/writes/queued-write.js +288 -0
  161. package/dist/writes/queued-write.js.map +1 -0
  162. package/dist/writes/receipt-store.d.ts +131 -0
  163. package/dist/writes/receipt-store.d.ts.map +1 -0
  164. package/dist/writes/receipt-store.js +360 -0
  165. package/dist/writes/receipt-store.js.map +1 -0
  166. package/dist/writes/worker.d.ts +30 -0
  167. package/dist/writes/worker.d.ts.map +1 -0
  168. package/dist/writes/worker.js +100 -0
  169. package/dist/writes/worker.js.map +1 -0
  170. package/openapi/openapi.json +18458 -17718
  171. package/package.json +4 -4
@@ -2,13 +2,18 @@ import { parseLiveDeclaration } from "@x12i/memorix-data";
2
2
  import { resolveScope } from "../request-scope.js";
3
3
  import { ServiceError } from "../errors.js";
4
4
  import { requireCallbackAccess, requireDeploymentAccess } from "../access-gate.js";
5
- import { normalizeWritePrecondition } from "../write-precondition.js";
6
- import { assertAddressMatchesEnvelope, resolveRecordIdAlias, } from "../identity-alias.js";
5
+ import { runContentBatch, runContentWrite } from "../writes/content-write.js";
6
+ import { pendingRecord, publicQueuedReceipt, queueKeyIdFor, readQueueFlag, runQueuedBatch, runQueuedWrite, } from "../writes/queued-write.js";
7
+ import { commitWriteEffects } from "../writes/effects.js";
8
+ import { patchGuardCheck } from "../patches/service.js";
9
+ import { resolveRecordIdAlias } from "../identity-alias.js";
7
10
  import { maybeEnqueueNexumAnalysis } from "../nexum-analysis/enqueue.js";
11
+ import { listKnowledgeGraphsForSource } from "../nexum-analysis/knowledge-read.js";
8
12
  import { memorixActivityRing, recordLooksLikeKnowledgeGraph, } from "../activity-ring.js";
9
13
  import { getRequestCaller, requireCaller } from "../caller-identity.js";
10
- import { runHookTrigger } from "../hooks/request-trigger.js";
11
14
  import { parseSafeFilters, parseSafeFiltersValue, parseSafeSort, parseSafeSortValue } from "../safe-query.js";
15
+ import { declaredDiscriminatorValues } from "../catalog-bridge.js";
16
+ import { singleReadDiscriminatorValue, withDiscriminatorHelp } from "../single-read-discriminator.js";
12
17
  function parseIncludeObject(raw) {
13
18
  if (raw === undefined)
14
19
  return undefined;
@@ -45,13 +50,27 @@ function subjectRecordIdFromRequest(req) {
45
50
  }
46
51
  return recordId;
47
52
  }
48
- function readSuppressHooks(value) {
49
- if (value === undefined)
50
- return false;
51
- if (typeof value !== "boolean") {
52
- throw new ServiceError("VALIDATION", "suppressHooks must be a boolean", 400);
53
- }
54
- return value;
53
+ function publicWriteReceipt(row) {
54
+ return {
55
+ receiptId: row.receiptId,
56
+ status: row.status,
57
+ ...(row.writeId ? { writeId: row.writeId } : {}),
58
+ createdAt: row.createdAt,
59
+ updatedAt: row.updatedAt,
60
+ ...(row.receipt ? { receipt: row.receipt } : {}),
61
+ ...(row.error
62
+ ? {
63
+ error: {
64
+ code: row.error.code,
65
+ field: row.error.field,
66
+ message: row.error.message,
67
+ retryable: row.error.retryable,
68
+ ...(row.error.retryAfterMs !== undefined ? { retryAfterMs: row.error.retryAfterMs } : {}),
69
+ },
70
+ }
71
+ : {}),
72
+ ...(row.status === "pending" ? { retryAfterMs: row.error?.retryAfterMs ?? 1_000 } : {}),
73
+ };
55
74
  }
56
75
  function contentDatasource(req, body) {
57
76
  const service = requireCaller(req);
@@ -75,7 +94,134 @@ function draftForDatasourceStamp(record) {
75
94
  ...(record.insights ? { insights: record.insights } : {}),
76
95
  };
77
96
  }
97
+ function readRunStatus(raw) {
98
+ if (raw === undefined || raw === "" || raw === "all")
99
+ return undefined;
100
+ if (raw === "queued" || raw === "stored" || raw === "rejected")
101
+ return raw;
102
+ throw new ServiceError("VALIDATION", "status must be queued, stored or rejected", 400, {
103
+ field: "status",
104
+ retryable: false,
105
+ });
106
+ }
107
+ /** The cursor is the accept number of the last row on the page. Callers treat it as opaque. */
108
+ function writeReceiptCursor(seq) {
109
+ return Buffer.from(String(seq), "utf8").toString("base64url");
110
+ }
111
+ function readReceiptCursor(raw) {
112
+ if (raw === undefined || raw === "")
113
+ return undefined;
114
+ const seq = Number(Buffer.from(raw, "base64url").toString("utf8"));
115
+ // Only a cursor this service wrote decodes and encodes back to itself. Anything else would
116
+ // silently decode to a number and restart the list from the top.
117
+ if (!Number.isInteger(seq) || seq < 1 || writeReceiptCursor(seq) !== raw) {
118
+ throw new ServiceError("VALIDATION", "cursor is not valid", 400, { field: "cursor", retryable: false });
119
+ }
120
+ return seq;
121
+ }
122
+ function isReadNotFound(err) {
123
+ return Boolean(err && typeof err === "object" && err.code === "READ_NOT_FOUND");
124
+ }
125
+ /** One content instance by recordId; `filter` may only select the discriminator. */
126
+ async function getSingleContent(platform, scope, q, recordId, include) {
127
+ const ports = platform.portsFor(scope);
128
+ const discriminatorPath = ports.catalog.getContentProfile?.(q.contentType)?.discriminator?.path;
129
+ const discriminatorValue = singleReadDiscriminatorValue({
130
+ discriminatorValue: q.discriminatorValue ?? q.discriminator,
131
+ filters: parseSafeFilters(q.filter),
132
+ discriminatorPath,
133
+ });
134
+ const queuedFor = async () => {
135
+ const keyId = queueKeyIdFor(ports.catalog, {
136
+ objectType: q.objectType,
137
+ contentType: q.contentType,
138
+ recordId,
139
+ discriminatorValue,
140
+ });
141
+ return keyId ? platform.writeReceiptsFor(scope.orgId).latestQueuedForKey(scope.orgId, keyId) : null;
142
+ };
143
+ let record;
144
+ try {
145
+ record = (await ports.reader.getContent(scope, {
146
+ objectType: q.objectType,
147
+ contentType: q.contentType,
148
+ recordId,
149
+ ...(discriminatorValue ? { discriminatorValue } : {}),
150
+ }, include ? { include } : undefined));
151
+ }
152
+ catch (err) {
153
+ if (isReadNotFound(err)) {
154
+ const pending = await queuedFor();
155
+ if (pending) {
156
+ return {
157
+ record: pendingRecord(pending),
158
+ stored: false,
159
+ pending,
160
+ ...(discriminatorValue ? { discriminatorValue } : {}),
161
+ };
162
+ }
163
+ }
164
+ throw withDiscriminatorHelp(err, () => discriminatorPath
165
+ ? declaredDiscriminatorValues(platform.metadata, scope.agentIds, q.objectType, q.contentType, discriminatorPath)
166
+ : undefined);
167
+ }
168
+ const pending = await queuedFor();
169
+ return {
170
+ record,
171
+ stored: true,
172
+ ...(discriminatorValue ? { discriminatorValue } : {}),
173
+ ...(pending ? { pending } : {}),
174
+ };
175
+ }
78
176
  export async function registerDataRoutes(app, platform) {
177
+ app.get("/api/data/knowledge-graphs", async (req) => {
178
+ requireCallbackAccess(req, "explore");
179
+ const scope = resolveScope(req);
180
+ const q = req.query;
181
+ const sourceRecordId = q.sourceRecordId?.trim() ?? "";
182
+ if (!sourceRecordId) {
183
+ throw new ServiceError("VALIDATION", "sourceRecordId required", 400);
184
+ }
185
+ const caller = getRequestCaller(req);
186
+ const ports = platform.portsFor(scope);
187
+ const listed = await listKnowledgeGraphsForSource({
188
+ reader: ports.reader,
189
+ scope,
190
+ sourceRecordId,
191
+ limit: Number(q.limit ?? 50) || 50,
192
+ cursor: q.cursor,
193
+ });
194
+ memorixActivityRing.recordFetch({
195
+ orgId: scope.orgId,
196
+ caller,
197
+ mode: "list",
198
+ objectType: listed.addressing.objectType,
199
+ contentType: listed.addressing.contentType,
200
+ recordId: sourceRecordId,
201
+ hit: listed.records.length > 0,
202
+ count: listed.records.length,
203
+ knowledgeGraph: listed.records.length > 0,
204
+ });
205
+ return { scope, ...listed };
206
+ });
207
+ app.get("/api/data/content/context", async (req) => {
208
+ requireCallbackAccess(req, "explore");
209
+ const scope = resolveScope(req);
210
+ const q = req.query;
211
+ if (!q.objectType || !q.contentType) {
212
+ throw new ServiceError("VALIDATION", "objectType and contentType required", 400);
213
+ }
214
+ const recordId = resolveRecordIdAlias(q.id, q.recordId);
215
+ if (!recordId) {
216
+ throw new ServiceError("VALIDATION", "recordId required", 400);
217
+ }
218
+ const { record } = await getSingleContent(platform, scope, { ...q, objectType: q.objectType, contentType: q.contentType }, recordId);
219
+ const context = record.context;
220
+ if (!context || typeof context !== "object" || Array.isArray(context) || Object.keys(context).length === 0) {
221
+ throw new ServiceError("NOT_FOUND", "context is not stored on this record", 404);
222
+ }
223
+ return context;
224
+ });
79
225
  app.get("/api/data/content", async (req) => {
80
226
  requireCallbackAccess(req, "explore");
81
227
  const scope = resolveScope(req);
@@ -90,12 +236,11 @@ export async function registerDataRoutes(app, platform) {
90
236
  const recordId = resolveRecordIdAlias(q.id, q.recordId);
91
237
  if (recordId) {
92
238
  let record;
239
+ let discriminatorValue;
240
+ let pending;
241
+ let stored = true;
93
242
  try {
94
- record = (await ports.reader.getContent(scope, {
95
- objectType: q.objectType,
96
- contentType: q.contentType,
97
- recordId,
98
- }, { include }));
243
+ ({ record, discriminatorValue, pending, stored } = await getSingleContent(platform, scope, { ...q, objectType: q.objectType, contentType: q.contentType }, recordId, include));
99
244
  }
100
245
  catch (err) {
101
246
  const msg = err instanceof Error ? err.message : String(err);
@@ -141,34 +286,44 @@ export async function registerDataRoutes(app, platform) {
141
286
  contentType: q.contentType,
142
287
  id: recordId,
143
288
  recordId,
289
+ ...(discriminatorValue ? { discriminatorValue } : {}),
144
290
  },
145
291
  record,
146
- currentRevision: record.revision,
292
+ // A key with only a queued submission has no revision yet. null cannot be sent as exact-revision.
293
+ currentRevision: stored ? record.revision : null,
294
+ ...(pending ? { pending: true, receiptId: pending.receiptId } : {}),
147
295
  unresolvedRelationships,
148
296
  };
149
297
  }
150
298
  const limit = Math.min(Math.max(Number(q.limit ?? 50) || 50, 1), 200);
151
299
  const filters = parseSafeFilters(q.filter);
152
300
  const sort = parseSafeSort(q.sort);
153
- if (q.count === "true" && platform.analytics) {
154
- const count = await platform.analytics.countIndexed(scope, {
155
- objectType: q.objectType,
156
- contentType: q.contentType,
157
- filters,
158
- });
159
- if (count !== null) {
160
- return {
161
- scope,
162
- count,
163
- addressing: {
164
- orgId: scope.orgId,
165
- objectType: q.objectType,
166
- contentType: q.contentType,
167
- id: null,
168
- recordId: null,
169
- },
170
- };
171
- }
301
+ if (q.count === "true") {
302
+ const indexed = platform.analytics
303
+ ? await platform.analytics.countIndexed(scope, {
304
+ objectType: q.objectType,
305
+ contentType: q.contentType,
306
+ filters,
307
+ })
308
+ : null;
309
+ const count = indexed !== null
310
+ ? indexed
311
+ : await ports.reader.countContent(scope, {
312
+ objectType: q.objectType,
313
+ contentType: q.contentType,
314
+ filters,
315
+ });
316
+ return {
317
+ scope,
318
+ count,
319
+ addressing: {
320
+ orgId: scope.orgId,
321
+ objectType: q.objectType,
322
+ contentType: q.contentType,
323
+ id: null,
324
+ recordId: null,
325
+ },
326
+ };
172
327
  }
173
328
  const listed = await ports.reader.listContent(scope, {
174
329
  objectType: q.objectType,
@@ -237,178 +392,122 @@ export async function registerDataRoutes(app, platform) {
237
392
  pageInfo: { nextCursor: listed.nextCursor ?? null, limit },
238
393
  };
239
394
  });
240
- app.post("/api/data/content", async (req) => {
395
+ app.post("/api/data/content/batch", async (req) => {
241
396
  requireDeploymentAccess(req, "write");
242
397
  const scope = resolveScope(req);
243
- const body = req.body;
244
- if (!body.objectType || !body.contentType) {
245
- throw new ServiceError("VALIDATION", "objectType, contentType required", 400);
246
- }
247
398
  await platform.analytics?.prepare(scope.orgId);
248
- assertAddressMatchesEnvelope({
249
- objectType: body.objectType,
250
- contentType: body.contentType,
251
- record: body.record,
252
- });
253
- const precondition = normalizeWritePrecondition(body.precondition, body.expectedRevision);
254
- const datasource = contentDatasource(req, body);
255
- const suppressHooks = readSuppressHooks(body.suppressHooks);
256
- const ports = platform.portsFor(scope);
257
- const op = body.op ?? (precondition.form === "absent" ? "add" : "upsert");
258
- const expectedRevision = precondition.form === "exact-revision" ? precondition.expectedRevision : undefined;
259
- try {
260
- if (op === "remove") {
261
- if (!body.record?.recordId || expectedRevision === undefined) {
262
- throw new ServiceError("VALIDATION", "record.recordId and expectedRevision required for remove", 400);
263
- }
264
- const pinned = await ports.publications.list();
265
- const blocking = pinned.filter((p) => (p.status === "committed" || p.status === "draft") &&
266
- p.members.some((m) => m.recordId === body.record.recordId &&
267
- m.objectType === body.objectType &&
268
- m.contentType === body.contentType));
269
- if (blocking.length) {
270
- throw new ServiceError("PUBLICATION_PINNED", "cannot remove a content instance referenced by a draft or committed publication", 409, { publicationIds: blocking.map((p) => p.publicationId) });
271
- }
272
- const objectType = body.objectType;
273
- const contentType = body.contentType;
274
- const recordId = body.record.recordId;
275
- const discriminatorValue = typeof body.discriminatorValue === "string" && body.discriminatorValue.trim()
276
- ? body.discriminatorValue.trim()
277
- : undefined;
278
- const noted = await runHookTrigger(() => ports.writer.remove({
279
- scope,
280
- objectType,
281
- contentType,
282
- recordId,
283
- ...(discriminatorValue ? { discriminatorValue } : {}),
284
- precondition: { kind: "revision", revision: expectedRevision },
285
- datasource,
286
- suppressHooks,
287
- }));
288
- const result = noted.value;
289
- memorixActivityRing.recordLand({
290
- orgId: scope.orgId,
291
- caller: getRequestCaller(req),
292
- objectType: body.objectType,
293
- contentType: body.contentType,
294
- recordId: result.recordId,
295
- revision: result.previousRevision,
296
- outcome: "removed",
297
- hooks: noted.hooks,
298
- });
299
- return { scope, written: true, outcome: "updated", result, precondition: body.precondition };
300
- }
301
- if (!body.record) {
302
- throw new ServiceError("VALIDATION", "record required", 400);
303
- }
304
- const record = {
305
- ...body.record,
306
- objectType: body.objectType,
307
- contentType: body.contentType,
308
- };
309
- if (record._system && typeof record._system === "object") {
310
- const system = { ...record._system };
311
- delete system._datasource;
312
- record._system = system;
313
- }
314
- let result;
315
- let hooks;
316
- const objectType = body.objectType;
317
- const contentType = body.contentType;
318
- const recordId = body.record.recordId;
319
- if (op === "add" || precondition.form === "absent") {
320
- const noted = await runHookTrigger(() => ports.writer.add({
321
- scope,
322
- precondition: { kind: "absent" },
323
- record,
324
- datasource,
325
- suppressHooks,
326
- }));
327
- result = noted.value;
328
- hooks = noted.hooks;
329
- }
330
- else if (op === "patch") {
331
- const noted = await runHookTrigger(() => ports.writer.patch({
332
- scope,
333
- precondition: { kind: "revision", revision: Number(expectedRevision) },
334
- objectType,
335
- contentType,
336
- recordId,
337
- ops: body.patch ?? [],
338
- datasource,
339
- suppressHooks,
340
- }));
341
- result = noted.value;
342
- hooks = noted.hooks;
343
- }
344
- else if (op === "replace") {
345
- const noted = await runHookTrigger(() => ports.writer.replace({
346
- scope,
347
- precondition: { kind: "revision", revision: Number(expectedRevision) },
348
- record,
349
- datasource,
350
- suppressHooks,
351
- }));
352
- result = noted.value;
353
- hooks = noted.hooks;
354
- }
355
- else {
356
- // exact-revision upsert — never silently treat as create
357
- const noted = await runHookTrigger(() => ports.writer.upsert({
358
- scope,
359
- precondition: { kind: "revision", revision: Number(expectedRevision) },
360
- record,
361
- datasource,
362
- suppressHooks,
363
- }));
364
- result = noted.value;
365
- hooks = noted.hooks;
366
- }
367
- const indexing = await maybeEnqueueNexumAnalysis({
368
- platform,
369
- scope,
370
- objectType: body.objectType,
371
- contentType: body.contentType,
372
- recordId: result.record.recordId,
373
- revision: result.record.revision,
374
- data: result.record.data &&
375
- typeof result.record.data === "object" &&
376
- !Array.isArray(result.record.data)
377
- ? result.record.data
378
- : body.record?.data &&
379
- typeof body.record.data === "object" &&
380
- !Array.isArray(body.record.data)
381
- ? body.record.data
382
- : undefined,
383
- });
384
- memorixActivityRing.recordLand({
399
+ const caller = requireCaller(req);
400
+ const queue = readQueueFlag(req.body?.queue);
401
+ return (queue ? runQueuedBatch : runContentBatch)({
402
+ scope,
403
+ caller,
404
+ datasource: contentDatasource(req, {}),
405
+ ports: platform.portsFor(scope),
406
+ receipts: platform.writeReceiptsFor(scope.orgId),
407
+ onCommitted: (event) => commitWriteEffects({ platform, scope, caller, event }),
408
+ checkWrite: patchGuardCheck(platform, scope),
409
+ }, req.body, (item) => contentDatasource(req, item));
410
+ });
411
+ app.get("/api/data/receipts", async (req) => {
412
+ requireCallbackAccess(req, "explore");
413
+ const scope = resolveScope(req);
414
+ const caller = requireCaller(req);
415
+ const q = req.query;
416
+ const limit = Math.min(Math.max(Number(q.limit ?? 50) || 50, 1), 200);
417
+ const runId = q.runId?.trim();
418
+ const receipts = platform.writeReceiptsFor(scope.orgId);
419
+ if (runId) {
420
+ // Queued writes of one run, in accept order. Omitting status returns every status.
421
+ const status = readRunStatus(q.status);
422
+ const stepId = q.stepId?.trim() || undefined;
423
+ const after = readReceiptCursor(q.cursor);
424
+ const rows = await receipts.listByRun({
385
425
  orgId: scope.orgId,
386
- caller: getRequestCaller(req),
387
- objectType: body.objectType,
388
- contentType: body.contentType,
389
- recordId: result.record.recordId,
390
- revision: result.record.revision,
391
- outcome: result.created ? "created" : "updated",
392
- indexing,
393
- hooks,
426
+ caller,
427
+ runId,
428
+ ...(stepId ? { stepId } : {}),
429
+ ...(status ? { status } : {}),
430
+ ...(after !== undefined ? { after } : {}),
431
+ limit: limit + 1,
394
432
  });
433
+ const page = rows.slice(0, limit);
434
+ const last = page[page.length - 1];
395
435
  return {
396
436
  scope,
397
- written: true,
398
- outcome: result.created ? "created" : "updated",
399
- result,
400
- currentRevision: result.record.revision,
401
- precondition: body.precondition,
402
- indexing,
437
+ receipts: page.map(publicQueuedReceipt),
438
+ pageInfo: {
439
+ nextCursor: rows.length > limit && last?.queue ? writeReceiptCursor(last.queue.seq) : null,
440
+ limit,
441
+ },
403
442
  };
404
443
  }
405
- catch (err) {
406
- const msg = err instanceof Error ? err.message : String(err);
407
- if (/stale|revision|WRITE_STALE/i.test(msg)) {
408
- throw new ServiceError("REVISION_CONFLICT", msg, 409);
409
- }
410
- throw err;
411
- }
444
+ if (q.stepId?.trim()) {
445
+ throw new ServiceError("VALIDATION", "stepId needs runId", 400, { field: "runId", retryable: false });
446
+ }
447
+ const status = q.status === "pending" ||
448
+ q.status === "queued" ||
449
+ q.status === "stored" ||
450
+ q.status === "rejected" ||
451
+ q.status === "all"
452
+ ? q.status
453
+ : "rejected";
454
+ const rows = await receipts.list({
455
+ orgId: scope.orgId,
456
+ caller,
457
+ ...(status === "all" ? {} : { status }),
458
+ limit,
459
+ });
460
+ return { scope, receipts: rows.map((row) => (row.queue ? publicQueuedReceipt(row) : publicWriteReceipt(row))) };
461
+ });
462
+ // A static segment wins over `:receiptId`, so `summary` is never read as a receipt id.
463
+ app.get("/api/data/receipts/summary", async (req) => {
464
+ requireCallbackAccess(req, "explore");
465
+ const scope = resolveScope(req);
466
+ const caller = requireCaller(req);
467
+ const q = req.query;
468
+ const runId = q.runId?.trim();
469
+ if (!runId)
470
+ throw new ServiceError("VALIDATION", "runId required", 400, { field: "runId", retryable: false });
471
+ const stepId = q.stepId?.trim() || undefined;
472
+ const counts = await platform
473
+ .writeReceiptsFor(scope.orgId)
474
+ .summarizeRun({ orgId: scope.orgId, caller, runId, ...(stepId ? { stepId } : {}) });
475
+ return { scope, runId, ...(stepId ? { stepId } : {}), ...counts };
476
+ });
477
+ app.get("/api/data/receipts/:receiptId", async (req) => {
478
+ requireCallbackAccess(req, "explore");
479
+ const scope = resolveScope(req);
480
+ const caller = requireCaller(req);
481
+ const receiptId = req.params.receiptId?.trim() ?? "";
482
+ if (!receiptId)
483
+ throw new ServiceError("VALIDATION", "receiptId required", 400, { field: "receiptId" });
484
+ const row = await platform.writeReceiptsFor(scope.orgId).get(scope.orgId, receiptId);
485
+ if (!row || row.caller !== caller) {
486
+ throw new ServiceError("NOT_FOUND", "receipt not found", 404, { field: "receiptId", retryable: false });
487
+ }
488
+ return row.queue ? publicQueuedReceipt(row) : publicWriteReceipt(row);
489
+ });
490
+ app.post("/api/data/content", async (req, reply) => {
491
+ requireDeploymentAccess(req, "write");
492
+ const scope = resolveScope(req);
493
+ const body = (req.body ?? {});
494
+ await platform.analytics?.prepare(scope.orgId);
495
+ const caller = requireCaller(req);
496
+ const queue = readQueueFlag(body.queue);
497
+ const outcome = await (queue ? runQueuedWrite : runContentWrite)({
498
+ scope,
499
+ caller,
500
+ datasource: contentDatasource(req, body),
501
+ ports: platform.portsFor(scope),
502
+ receipts: platform.writeReceiptsFor(scope.orgId),
503
+ onCommitted: (event) => commitWriteEffects({ platform, scope, caller, event }),
504
+ checkWrite: patchGuardCheck(platform, scope),
505
+ }, body);
506
+ if (outcome.kind === "rejected")
507
+ throw outcome.error;
508
+ if (outcome.kind === "pending" || outcome.kind === "queued")
509
+ return reply.code(202).send(outcome.body);
510
+ return outcome.body;
412
511
  });
413
512
  app.post("/api/data/content/move-fields", async (req) => {
414
513
  requireDeploymentAccess(req, "write");