@segmentstream-cloud/ontology 0.15.0 → 0.17.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,536 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES } from "../blob.js";
4
+ import { ActionRejected, Conflict, NotFound } from "../errors.js";
5
+ import { canonicalJson } from "../manifest.js";
6
+ import { actionInputJsonSchema } from "../runtime/generated-actions.js";
7
+ import { resolveAction } from "../runtime/pipeline.js";
8
+ import { authorizeBlobOperation } from "./authorization.js";
9
+ import { ATTACHMENTS_TABLE } from "./tables.js";
10
+ /**
11
+ * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
12
+ * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
13
+ * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
14
+ * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
15
+ * promote it into a record through an action, which adopts the same stored object without copying
16
+ * it. Every operation re-derives the owner, chat and session from the caller, never from input.
17
+ */
18
+ /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
19
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
20
+ /** How long an attachment stays readable after it arrives; reads never extend it. */
21
+ export const ATTACHMENT_TTL_HOURS = 24;
22
+ /** Live attachments one member may hold at once, so a busy chat cannot fill the storage. */
23
+ export const MAX_LIVE_ATTACHMENTS = 50;
24
+ export const ATTACHMENT_SOURCES = ["telegram"];
25
+ const SHA256 = /^[0-9a-f]{64}$/;
26
+ const SELECT = `SELECT a.id::text, a.blob_id::text, b.key, a.owner_id::text, a.conversation,
27
+ a.session_id, a.filename, a.content_type, a.size::text, a.sha256, a.state, a.provider_upload_id,
28
+ a.promotion,
29
+ to_char(a.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_at,
30
+ to_char(a.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS expires_at,
31
+ a.expires_at <= now() AS expired
32
+ FROM "${ATTACHMENTS_TABLE}" a JOIN "_blobs" b ON b.id = a.blob_id`;
33
+ const READABLE = new Set(["ready", "promoted"]);
34
+ /** The fields of an action's input that take an upload, with the property each one writes. */
35
+ export function uploadFields(input) {
36
+ const schema = actionInputJsonSchema(input);
37
+ const definitions = (schema.$defs ?? {});
38
+ const resolve = (value, depth = 0) => {
39
+ if (typeof value !== "object" || value === null || depth > 8) {
40
+ return undefined;
41
+ }
42
+ const object = value;
43
+ const reference = object.$ref;
44
+ return typeof reference === "string" && reference.startsWith("#/$defs/")
45
+ ? resolve(definitions[reference.slice("#/$defs/".length)], depth + 1)
46
+ : object;
47
+ };
48
+ const binding = (value, depth = 0) => {
49
+ const object = resolve(value);
50
+ if (object === undefined || depth > 8) {
51
+ return undefined;
52
+ }
53
+ const annotation = object[BLOB_UPLOAD_ANNOTATION];
54
+ if (typeof annotation?.entity === "string" && typeof annotation.property === "string") {
55
+ return { entity: annotation.entity, property: annotation.property };
56
+ }
57
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
58
+ const options = object[key];
59
+ if (Array.isArray(options)) {
60
+ for (const option of options) {
61
+ const found = binding(option, depth + 1);
62
+ if (found !== undefined) {
63
+ return found;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return undefined;
69
+ };
70
+ const root = resolve(schema);
71
+ const properties = (root?.properties ?? {});
72
+ return Object.entries(properties).flatMap(([field, value]) => {
73
+ const found = binding(value);
74
+ return found === undefined ? [] : [{ field, ...found }];
75
+ });
76
+ }
77
+ /** Whether the action's input has a top-level `sha256` field the bridge may fill in. */
78
+ function takesSha256(input) {
79
+ const schema = actionInputJsonSchema(input);
80
+ const reference = schema.$ref;
81
+ const root = typeof reference === "string" && reference.startsWith("#/$defs/")
82
+ ? schema.$defs?.[reference.slice("#/$defs/".length)]
83
+ : schema;
84
+ const properties = root?.properties;
85
+ return properties !== undefined && "sha256" in properties;
86
+ }
87
+ function view(row) {
88
+ return {
89
+ id: row.id,
90
+ filename: row.filename,
91
+ contentType: row.content_type,
92
+ size: Number(row.size),
93
+ sha256: row.sha256,
94
+ state: row.expired && (row.state === "ready" || row.state === "received") ? "expired" : row.state,
95
+ createdAt: row.created_at,
96
+ expiresAt: row.expires_at,
97
+ promotion: row.promotion ?? null,
98
+ };
99
+ }
100
+ /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
101
+ export function attachmentRuntime(ontology, sql, provider) {
102
+ const binding = provider.binding;
103
+ const missing = (id) => new NotFound({ entity: "attachment", id, message: "Attachment not found." });
104
+ const unavailable = (message) => new Conflict({ entity: "attachment", message });
105
+ /**
106
+ * The caller's attachment, locked. Another member's, another chat's or another session's is not
107
+ * found: it does not exist for this caller. The first session that loads it owns it.
108
+ */
109
+ const load = (id, caller,
110
+ /** `source`: the webhook redelivering the file it stored, before or after a session read it. */
111
+ scope = "session") => Effect.gen(function* () {
112
+ if (!/^[0-9a-f-]{36}$/i.test(id)) {
113
+ return yield* missing(id);
114
+ }
115
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND a.owner_id = $2::uuid AND a.conversation = $3
116
+ AND a.branch_id = $4 FOR UPDATE OF a, b`, [id, caller.principal.id, caller.conversation, binding.branchId]);
117
+ const row = rows[0];
118
+ if (!row) {
119
+ return yield* missing(id);
120
+ }
121
+ if (scope === "session" && row.session_id !== null && row.session_id !== caller.sessionId) {
122
+ return yield* missing(id);
123
+ }
124
+ if (scope === "session" && row.session_id === null && caller.sessionId !== undefined) {
125
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET session_id = $1 WHERE id = $2::uuid`, [
126
+ caller.sessionId,
127
+ row.id,
128
+ ]);
129
+ }
130
+ // The member must still be active; a deactivated member's agent reads nothing.
131
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
132
+ return row;
133
+ });
134
+ /**
135
+ * Reserves the attachment and its revision, and answers where to put the bytes. A retry of the
136
+ * same source file answers the same attachment.
137
+ */
138
+ const receive = (input, caller) => Effect.gen(function* () {
139
+ if (!ATTACHMENT_SOURCES.includes(input.source) ||
140
+ input.sourceRef === "" ||
141
+ input.sourceRef.length > 200 ||
142
+ input.filename === "" ||
143
+ input.filename.length > 255 ||
144
+ /[\x00-\x1f\x7f/\\]/.test(input.filename) ||
145
+ !/^[\w.+-]+\/[\w.+-]+$/.test(input.contentType) ||
146
+ !Number.isInteger(input.size) ||
147
+ input.size <= 0 ||
148
+ !SHA256.test(input.sha256)) {
149
+ return yield* new ActionRejected({ message: "The attachment's metadata is invalid." });
150
+ }
151
+ if (input.size > MAX_ATTACHMENT_BYTES) {
152
+ return yield* new ActionRejected({
153
+ message: `The file is larger than the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB the agent accepts.`,
154
+ });
155
+ }
156
+ const reserved = yield* sql.withTransaction(Effect.gen(function* () {
157
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
158
+ yield* sql.unsafe(`SELECT pg_advisory_xact_lock(818724)`);
159
+ const [existing] = yield* sql.unsafe(`SELECT id::text FROM "${ATTACHMENTS_TABLE}"
160
+ WHERE owner_id = $1::uuid AND conversation = $2 AND source_ref = $3`, [caller.principal.id, caller.conversation, input.sourceRef]);
161
+ if (existing) {
162
+ const row = yield* load(existing.id, caller, "source");
163
+ if (row.sha256 !== input.sha256 || Number(row.size) !== input.size) {
164
+ return yield* unavailable("Another file was already received for this message.");
165
+ }
166
+ return row;
167
+ }
168
+ const [usage] = yield* sql.unsafe(`SELECT (SELECT coalesce(sum(size), 0)::text FROM "_blobs" WHERE state <> 'deleted') AS bytes,
169
+ (SELECT count(*)::text FROM "${ATTACHMENTS_TABLE}" WHERE owner_id = $1::uuid
170
+ AND state IN ('received', 'ready') AND expires_at > now()) AS live`, [caller.principal.id]);
171
+ if (Number(usage.bytes) + input.size > binding.quotaBytes ||
172
+ Number(usage.live) >= MAX_LIVE_ATTACHMENTS) {
173
+ return yield* new ActionRejected({
174
+ message: "File storage capacity or the member's attachment limit is reached.",
175
+ });
176
+ }
177
+ const id = randomUUID();
178
+ const blobId = randomUUID();
179
+ yield* sql.unsafe(`INSERT INTO "_blobs" (id, branch_id, key, filename, content_type, size, state)
180
+ VALUES ($1::uuid, $2, $3, $4, $5, $6::bigint, 'ready')`, [
181
+ blobId,
182
+ binding.branchId,
183
+ `revisions/${blobId}`,
184
+ input.filename,
185
+ input.contentType,
186
+ input.size,
187
+ ]);
188
+ yield* sql.unsafe(`INSERT INTO "${ATTACHMENTS_TABLE}"
189
+ (id, blob_id, branch_id, owner_id, conversation, session_id, source, source_ref,
190
+ filename, content_type, size, sha256, state, expires_at)
191
+ VALUES ($1::uuid, $2::uuid, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11::bigint, $12,
192
+ 'received', now() + make_interval(hours => ${ATTACHMENT_TTL_HOURS}))`, [
193
+ id,
194
+ blobId,
195
+ binding.branchId,
196
+ caller.principal.id,
197
+ caller.conversation,
198
+ caller.sessionId ?? null,
199
+ input.source,
200
+ input.sourceRef,
201
+ input.filename,
202
+ input.contentType,
203
+ input.size,
204
+ input.sha256,
205
+ ]);
206
+ return yield* load(id, caller);
207
+ }));
208
+ if (reserved.state !== "received" || reserved.expired) {
209
+ return { attachment: view(reserved) };
210
+ }
211
+ const pending = reserved.provider_upload_id
212
+ ? [reserved.provider_upload_id]
213
+ : yield* provider.pending(reserved.key);
214
+ const uploadId = pending[0] ??
215
+ (yield* provider.initiate({
216
+ key: reserved.key,
217
+ filename: reserved.filename,
218
+ contentType: reserved.content_type,
219
+ sessionId: reserved.id,
220
+ }));
221
+ if (reserved.provider_upload_id !== uploadId) {
222
+ yield* sql.withTransaction(sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET provider_upload_id = $1
223
+ WHERE id = $2::uuid AND state = 'received'`, [uploadId, reserved.id]));
224
+ }
225
+ const upload = yield* provider.signPart({
226
+ key: reserved.key,
227
+ uploadId,
228
+ partNumber: 1,
229
+ size: Number(reserved.size),
230
+ });
231
+ return { attachment: view(reserved), upload };
232
+ });
233
+ /** Completes the transfer, then verifies the stored bytes against the declared size and digest. */
234
+ const complete = (id, caller) => Effect.gen(function* () {
235
+ const row = yield* sql.withTransaction(load(id, caller));
236
+ if (row.state !== "received") {
237
+ return READABLE.has(row.state) && !row.expired
238
+ ? view(row)
239
+ : yield* unavailable("The attachment is no longer available.");
240
+ }
241
+ if (row.expired || !row.provider_upload_id) {
242
+ return yield* unavailable("The attachment's transfer did not start or has expired.");
243
+ }
244
+ let stored = yield* provider.inspect(row.key);
245
+ if (!stored) {
246
+ const parts = yield* provider.parts({ key: row.key, uploadId: row.provider_upload_id });
247
+ if (parts.length !== 1 || parts[0]?.number !== 1 || parts[0].size !== Number(row.size)) {
248
+ return yield* new ActionRejected({
249
+ message: "The transferred file does not match its declared size.",
250
+ });
251
+ }
252
+ yield* provider.complete({ key: row.key, uploadId: row.provider_upload_id, parts });
253
+ stored = yield* provider.inspect(row.key);
254
+ }
255
+ const bytes = stored ? yield* provider.read(row.key, MAX_ATTACHMENT_BYTES) : undefined;
256
+ const digest = bytes ? createHash("sha256").update(bytes).digest("hex") : undefined;
257
+ if (!stored ||
258
+ stored.size !== Number(row.size) ||
259
+ stored.sessionId !== row.id ||
260
+ digest !== row.sha256) {
261
+ yield* sql.withTransaction(sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'discarded', expires_at = now()
262
+ WHERE id = $1::uuid AND state = 'received'`, [row.id]));
263
+ return yield* new ActionRejected({
264
+ message: "The transferred file does not match its declared size or digest.",
265
+ });
266
+ }
267
+ return yield* sql.withTransaction(Effect.gen(function* () {
268
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'ready'
269
+ WHERE id = $1::uuid AND state = 'received'`, [row.id]);
270
+ return view(yield* load(row.id, caller));
271
+ }));
272
+ });
273
+ /** The caller's attachments in this chat and session that are still readable, newest first. */
274
+ const list = (caller) => sql.withTransaction(Effect.gen(function* () {
275
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
276
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.owner_id = $1::uuid AND a.conversation = $2 AND a.branch_id = $3
277
+ AND a.state IN ('ready', 'promoted') AND a.expires_at > now()
278
+ AND (a.session_id IS NULL OR a.session_id = $4)
279
+ ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [caller.principal.id, caller.conversation, binding.branchId, caller.sessionId ?? null]);
280
+ return rows.map(view);
281
+ }));
282
+ /**
283
+ * A one-minute grant to read the attachment's bytes, for the agent's runtime only. An expired,
284
+ * discarded or unfinished attachment cannot be read.
285
+ */
286
+ const read = (id, caller) => Effect.gen(function* () {
287
+ const row = yield* sql.withTransaction(load(id, caller));
288
+ if (!READABLE.has(row.state) || row.expired) {
289
+ return yield* unavailable("The attachment expired or was discarded.");
290
+ }
291
+ const download = yield* provider.download({
292
+ key: row.key,
293
+ filename: row.filename,
294
+ contentType: row.content_type,
295
+ inline: false,
296
+ });
297
+ return { attachment: view(row), download };
298
+ });
299
+ /** Revokes the caller's access at once; maintenance removes the bytes. */
300
+ const discard = (id, caller) => sql.withTransaction(Effect.gen(function* () {
301
+ const row = yield* load(id, caller);
302
+ if (row.state === "promoted") {
303
+ return yield* unavailable("The attachment was promoted; its file belongs to the record.");
304
+ }
305
+ if (row.state === "received" || row.state === "ready") {
306
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'discarded', expires_at = now()
307
+ WHERE id = $1::uuid`, [row.id]);
308
+ // A ticket a failed promotion left behind goes with it.
309
+ yield* sql.unsafe(`UPDATE "_blob_uploads" SET state = 'canceled', expires_at = now(), claim = NULL
310
+ WHERE blob_id = $1::uuid AND state = 'ready'`, [row.blob_id]);
311
+ }
312
+ return view(yield* load(row.id, caller));
313
+ }));
314
+ /**
315
+ * Adopts the attachment's stored object into a record through an action, as the caller and with
316
+ * their permissions: a completed upload ticket is minted on the existing revision, and the action
317
+ * consumes it in its own transaction. Nothing is copied or uploaded again. A file whose SHA-256
318
+ * an earlier attachment already promoted through the same action is not promoted twice: the
319
+ * earlier promotion is the answer. A retry with the same target replays the action's outcome.
320
+ */
321
+ const promote = (id, target, caller, run) => Effect.gen(function* () {
322
+ const found = resolveAction(ontology, target.entity, target.action);
323
+ if (!found) {
324
+ return yield* new ActionRejected({
325
+ message: `${target.entity}.${target.action} is not an action of the ontology.`,
326
+ });
327
+ }
328
+ const fields = uploadFields(found.action.input).filter((candidate) => target.field === undefined || candidate.field === target.field);
329
+ if (fields.length !== 1) {
330
+ const where = target.field === undefined ? "" : ` in ${target.field}`;
331
+ return yield* new ActionRejected({
332
+ message: fields.length === 0
333
+ ? `${target.entity}.${target.action} takes no file${where}.`
334
+ : `${target.entity}.${target.action} takes several files; name the field.`,
335
+ });
336
+ }
337
+ const field = fields[0];
338
+ const fileEntity = ontology.manifest.entities.find((item) => item.name === field.entity);
339
+ const property = fileEntity?.properties.find((item) => item.name === field.property && item.type === "blob");
340
+ if (!fileEntity || fileEntity.source || !property) {
341
+ return yield* new ActionRejected({ message: "The action's file field is invalid." });
342
+ }
343
+ if (target.recordId !== undefined && !/^[0-9a-f-]{36}$/i.test(target.recordId)) {
344
+ return yield* new ActionRejected({ message: "The record id is invalid." });
345
+ }
346
+ if (field.field in target.input) {
347
+ return yield* new ActionRejected({
348
+ message: `Leave ${field.field} out of the input; the attachment fills it.`,
349
+ });
350
+ }
351
+ const qualified = `${target.entity}.${target.action}`;
352
+ const prepared = yield* sql.withTransaction(Effect.gen(function* () {
353
+ const row = yield* load(id, caller);
354
+ if (row.state === "promoted") {
355
+ return { kind: "done", row };
356
+ }
357
+ if (row.state !== "ready" || row.expired) {
358
+ return yield* unavailable("The attachment expired or was discarded.");
359
+ }
360
+ if (!property.accept?.includes(row.content_type) ||
361
+ Number(row.size) > (property.maxBytes ?? MAX_BLOB_BYTES)) {
362
+ return yield* new ActionRejected({
363
+ message: `${field.entity}.${field.property} accepts ${(property.accept ?? []).join(", ")} ` +
364
+ `up to ${property.maxBytes ?? MAX_BLOB_BYTES} bytes; this file is ` +
365
+ `${row.content_type}, ${row.size} bytes.`,
366
+ });
367
+ }
368
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions);
369
+ const [earlier] = yield* sql.unsafe(`SELECT id::text, promotion FROM "${ATTACHMENTS_TABLE}"
370
+ WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
371
+ AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
372
+ ORDER BY created_at LIMIT 1`, [row.sha256, binding.branchId, row.id, qualified]);
373
+ if (earlier) {
374
+ const promotion = {
375
+ ...earlier.promotion,
376
+ deduplicated_from: earlier.id,
377
+ };
378
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'promoted', promotion = $1::jsonb
379
+ WHERE id = $2::uuid`, [JSON.stringify(promotion), row.id]);
380
+ return { kind: "done", row: yield* load(row.id, caller) };
381
+ }
382
+ const [existing] = yield* sql.unsafe(`SELECT id::text, state, record_id::text, create_intent::text FROM "_blob_uploads"
383
+ WHERE blob_id = $1::uuid FOR UPDATE`, [row.blob_id]);
384
+ if (existing && existing.state === "consumed") {
385
+ // An earlier promotion committed but its answer was lost; the action replays it.
386
+ return { kind: "run", row, upload: existing };
387
+ }
388
+ if (existing && existing.state !== "ready") {
389
+ return yield* unavailable("The attachment's file is being removed.");
390
+ }
391
+ const upload = {
392
+ id: existing?.id ?? randomUUID(),
393
+ state: "ready",
394
+ record_id: target.recordId?.toLowerCase() ?? null,
395
+ create_intent: target.recordId === undefined ? randomUUID() : null,
396
+ };
397
+ const values = [
398
+ upload.id,
399
+ row.blob_id,
400
+ caller.principal.id,
401
+ binding.branchId,
402
+ ontology.manifest.hash,
403
+ qualified,
404
+ fileEntity.id,
405
+ property.name,
406
+ upload.record_id,
407
+ upload.create_intent,
408
+ row.filename,
409
+ row.content_type,
410
+ row.size,
411
+ row.id,
412
+ ];
413
+ if (existing) {
414
+ yield* sql.unsafe(`UPDATE "_blob_uploads" SET blob_id = $2::uuid, principal_id = $3::uuid,
415
+ branch_id = $4, manifest_hash = $5, action = $6, entity_id = $7, property_id = $8,
416
+ record_id = $9::uuid, create_intent = $10::uuid, filename = $11,
417
+ content_type = $12, size = $13::bigint,
418
+ expires_at = (SELECT expires_at FROM "${ATTACHMENTS_TABLE}" WHERE id = $14::uuid)
419
+ WHERE id = $1::uuid`, values);
420
+ }
421
+ else {
422
+ yield* sql.unsafe(`INSERT INTO "_blob_uploads"
423
+ (id, blob_id, principal_id, branch_id, manifest_hash, action, entity_id, property_id,
424
+ record_id, create_intent, filename, content_type, size, state, expires_at)
425
+ VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9::uuid, $10::uuid, $11,
426
+ $12, $13::bigint, 'ready',
427
+ (SELECT expires_at FROM "${ATTACHMENTS_TABLE}" WHERE id = $14::uuid))`, values);
428
+ }
429
+ return { kind: "run", row, upload };
430
+ }));
431
+ if (prepared.kind === "done") {
432
+ return {
433
+ attachment: view(prepared.row),
434
+ deduplicated: Boolean(prepared.row.promotion?.deduplicated_from),
435
+ result: prepared.row.promotion?.result ?? null,
436
+ };
437
+ }
438
+ const { row, upload } = prepared;
439
+ const input = {
440
+ ...target.input,
441
+ [field.field]: { upload_id: upload.id },
442
+ };
443
+ if (!("sha256" in input) && takesSha256(found.action.input)) {
444
+ input.sha256 = row.sha256;
445
+ }
446
+ // The same attachment, target and input always run under the same key, so a retry replays.
447
+ const idempotencyKey = createHash("sha256")
448
+ .update(canonicalJson({ attachment: row.id, qualified, input }))
449
+ .digest("hex");
450
+ const outcome = yield* run({
451
+ entity: target.entity,
452
+ action: target.action,
453
+ input,
454
+ options: {
455
+ idempotencyKey,
456
+ ...(upload.create_intent === null ? {} : { createIntent: upload.create_intent }),
457
+ },
458
+ });
459
+ const promotion = {
460
+ action: qualified,
461
+ result: outcome.result ?? null,
462
+ promoted_at: new Date().toISOString(),
463
+ };
464
+ const promoted = yield* sql.withTransaction(Effect.gen(function* () {
465
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'promoted', promotion = $1::jsonb
466
+ WHERE id = $2::uuid AND state = 'ready'`, [JSON.stringify(promotion), row.id]);
467
+ return yield* load(row.id, caller);
468
+ }));
469
+ return { attachment: view(promoted), deduplicated: false, result: outcome.result ?? null };
470
+ });
471
+ return { receive, complete, list, read, discard, promote };
472
+ }
473
+ /**
474
+ * Expires attachments past their deadline and removes the bytes of every expired or discarded one
475
+ * that no record adopted. A revision a promotion ticket still points at is left to the upload
476
+ * maintenance, once the ticket is canceled. Runs from the storage maintenance schedule, so files
477
+ * expire while the agent is idle.
478
+ */
479
+ export function maintainAttachments(sql, provider, limit = 100) {
480
+ return Effect.gen(function* () {
481
+ const [table] = yield* sql `
482
+ SELECT to_regclass(${`public.${ATTACHMENTS_TABLE}`})::text AS present
483
+ `;
484
+ if (!table?.present) {
485
+ return { expired: 0, removed: 0 };
486
+ }
487
+ const expired = yield* sql.withTransaction(Effect.gen(function* () {
488
+ const rows = yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'expired'
489
+ WHERE state IN ('received', 'ready') AND expires_at <= now() RETURNING id`);
490
+ yield* sql.unsafe(`UPDATE "_blob_uploads" u SET state = 'canceled', expires_at = now(), claim = NULL
491
+ FROM "${ATTACHMENTS_TABLE}" a
492
+ WHERE u.blob_id = a.blob_id AND u.state = 'ready'
493
+ AND (a.state IN ('discarded', 'expired') OR a.expires_at <= now())`);
494
+ return rows.length;
495
+ }));
496
+ let removed = 0;
497
+ for (let count = 0; count < limit; count += 1) {
498
+ const candidate = yield* sql.withTransaction(Effect.gen(function* () {
499
+ const [row] = yield* sql.unsafe(`SELECT a.id::text, a.blob_id::text, b.key, a.provider_upload_id
500
+ FROM "${ATTACHMENTS_TABLE}" a JOIN "_blobs" b ON b.id = a.blob_id
501
+ WHERE a.removed_at IS NULL AND b.state IN ('ready', 'deleting')
502
+ AND (a.state IN ('discarded', 'expired') OR a.expires_at <= now()
503
+ OR a.branch_id <> $1)
504
+ AND NOT EXISTS (SELECT 1 FROM "_blob_references" r WHERE r.blob_id = b.id)
505
+ AND NOT EXISTS (SELECT 1 FROM "_blob_uploads" u WHERE u.blob_id = b.id)
506
+ ORDER BY a.created_at LIMIT 1 FOR UPDATE OF a, b SKIP LOCKED`, [provider.binding.branchId]);
507
+ if (!row) {
508
+ return undefined;
509
+ }
510
+ yield* sql.unsafe(`UPDATE "_blobs" SET state = 'deleting' WHERE id = $1::uuid`, [
511
+ row.blob_id,
512
+ ]);
513
+ return row;
514
+ }));
515
+ if (!candidate) {
516
+ break;
517
+ }
518
+ const handles = new Set([
519
+ ...(candidate.provider_upload_id ? [candidate.provider_upload_id] : []),
520
+ ...(yield* provider.pending(candidate.key)),
521
+ ]);
522
+ for (const uploadId of handles) {
523
+ yield* provider.abort({ key: candidate.key, uploadId });
524
+ }
525
+ yield* provider.remove(candidate.key);
526
+ yield* sql.withTransaction(Effect.gen(function* () {
527
+ yield* sql.unsafe(`UPDATE "_blobs" SET state = 'deleted' WHERE id = $1::uuid AND state = 'deleting'`, [candidate.blob_id]);
528
+ yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET removed_at = now(),
529
+ state = CASE WHEN state IN ('received', 'ready') THEN 'expired' ELSE state END
530
+ WHERE id = $1::uuid`, [candidate.id]);
531
+ }));
532
+ removed += 1;
533
+ }
534
+ return { expired, removed };
535
+ });
536
+ }
@@ -1,7 +1,10 @@
1
1
  import { Effect } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
3
  import type { BlobProvider } from "./provider.js";
4
- /** A scheduler-owned capability: lifecycle operations only, with persisted deletion fencing. */
4
+ /**
5
+ * A scheduler-owned capability: lifecycle operations only, with persisted deletion fencing. The
6
+ * agent's attachments expire first, so that their files are removed in the same run.
7
+ */
5
8
  export declare function maintainBlobStorage(sql: SqlClient.SqlClient, provider: BlobProvider["Service"], limit?: number): Effect.Effect<{
6
9
  removed: number;
7
10
  }, import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
@@ -1,9 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Effect } from "effect";
3
- /** A scheduler-owned capability: lifecycle operations only, with persisted deletion fencing. */
3
+ import { maintainAttachments } from "./attachments.js";
4
+ /**
5
+ * A scheduler-owned capability: lifecycle operations only, with persisted deletion fencing. The
6
+ * agent's attachments expire first, so that their files are removed in the same run.
7
+ */
4
8
  export function maintainBlobStorage(sql, provider, limit = 100) {
5
9
  return Effect.gen(function* () {
6
- let removed = 0;
10
+ const attachments = yield* maintainAttachments(sql, provider, limit);
11
+ let removed = attachments.removed;
7
12
  for (let count = 0; count < limit; count += 1) {
8
13
  const claim = randomUUID();
9
14
  const candidate = yield* sql.withTransaction(Effect.gen(function* () {
@@ -175,6 +175,20 @@ export function neonBlobProvider(binding) {
175
175
  }
176
176
  return response.Body.transformToByteArray();
177
177
  }),
178
+ read: (key, maxBytes) => attempt("blob.read", async () => {
179
+ const response = await client.send(new GetObjectCommand(object(key)));
180
+ if (!response.Body) {
181
+ throw new Error("Neon returned no object body.");
182
+ }
183
+ if (response.ContentLength !== undefined && response.ContentLength > maxBytes) {
184
+ throw new Error("The stored object exceeds the size it may have.");
185
+ }
186
+ const bytes = await response.Body.transformToByteArray();
187
+ if (bytes.byteLength > maxBytes) {
188
+ throw new Error("The stored object exceeds the size it may have.");
189
+ }
190
+ return bytes;
191
+ }),
178
192
  download: ({ key, filename, contentType, inline }) => attempt("blob.download", async () => {
179
193
  await client.send(new HeadObjectCommand(object(key)));
180
194
  const signedAt = signingDate();
@@ -61,6 +61,8 @@ declare const BlobProvider_base: Context.ServiceClass<BlobProvider, "@segmentstr
61
61
  }) => Effect.Effect<void, BlobStorageError>;
62
62
  readonly inspect: (key: string) => Effect.Effect<StoredObject | undefined, BlobStorageError>;
63
63
  readonly readPrefix: (key: string) => Effect.Effect<Uint8Array, BlobStorageError>;
64
+ /** The whole object, at most `maxBytes`; for verifying an agent attachment's digest. */
65
+ readonly read: (key: string, maxBytes: number) => Effect.Effect<Uint8Array, BlobStorageError>;
64
66
  readonly download: (input: {
65
67
  key: string;
66
68
  filename: string;
@@ -1,2 +1,10 @@
1
1
  /** Storage metadata lives in the ontology database and is migrated by its SDK. */
2
2
  export declare const BLOB_TABLES_DDL: ReadonlyArray<string>;
3
+ /** The agent's temporary attachments, beside the blob tables whose storage they share. */
4
+ export declare const ATTACHMENTS_TABLE = "_agent_attachments";
5
+ /**
6
+ * A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
7
+ * a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
8
+ * and the agent session that received them, and expires 24 hours after receipt unless promoted.
9
+ */
10
+ export declare const ATTACHMENT_TABLES_DDL: ReadonlyArray<string>;
@@ -53,3 +53,35 @@ export const BLOB_TABLES_DDL = [
53
53
  PRIMARY KEY ("principal_id", "key")
54
54
  )`,
55
55
  ];
56
+ /** The agent's temporary attachments, beside the blob tables whose storage they share. */
57
+ export const ATTACHMENTS_TABLE = "_agent_attachments";
58
+ /**
59
+ * A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
60
+ * a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
61
+ * and the agent session that received them, and expires 24 hours after receipt unless promoted.
62
+ */
63
+ export const ATTACHMENT_TABLES_DDL = [
64
+ `CREATE TABLE IF NOT EXISTS "${ATTACHMENTS_TABLE}" (
65
+ "id" uuid PRIMARY KEY,
66
+ "blob_id" uuid NOT NULL UNIQUE REFERENCES "_blobs" ("id"),
67
+ "branch_id" text NOT NULL,
68
+ "owner_id" uuid NOT NULL REFERENCES "user" ("id"),
69
+ "conversation" text NOT NULL,
70
+ "session_id" text,
71
+ "source" text NOT NULL CHECK ("source" IN ('telegram')),
72
+ "source_ref" text NOT NULL,
73
+ "filename" text NOT NULL,
74
+ "content_type" text NOT NULL,
75
+ "size" bigint NOT NULL CHECK ("size" > 0),
76
+ "sha256" text NOT NULL CHECK ("sha256" ~ '^[0-9a-f]{64}$'),
77
+ "state" text NOT NULL CHECK ("state" IN ('received', 'ready', 'promoted', 'discarded', 'expired')),
78
+ "provider_upload_id" text,
79
+ "promotion" jsonb,
80
+ "created_at" timestamptz NOT NULL DEFAULT now(),
81
+ "expires_at" timestamptz NOT NULL DEFAULT now() + interval '24 hours',
82
+ "removed_at" timestamptz,
83
+ UNIQUE ("owner_id", "conversation", "source_ref")
84
+ )`,
85
+ `CREATE INDEX IF NOT EXISTS "${ATTACHMENTS_TABLE}_expiry_idx" ON "${ATTACHMENTS_TABLE}" ("state", "expires_at")`,
86
+ `CREATE INDEX IF NOT EXISTS "${ATTACHMENTS_TABLE}_sha256_idx" ON "${ATTACHMENTS_TABLE}" ("sha256")`,
87
+ ];