@segmentstream-cloud/ontology 0.15.0 → 0.16.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,117 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { ActionRejected, Conflict, NotFound, type PermissionDenied } from "../errors.js";
4
+ import type { Ontology } from "../ontology.js";
5
+ import type { Principal } from "../runtime.js";
6
+ import type { BlobProvider, TransferGrant } from "./provider.js";
7
+ /**
8
+ * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
9
+ * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
10
+ * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
11
+ * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
12
+ * promote it into a record through an action, which adopts the same stored object without copying
13
+ * it. Every operation re-derives the owner, chat and session from the caller, never from input.
14
+ */
15
+ /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
16
+ export declare const MAX_ATTACHMENT_BYTES: number;
17
+ /** How long an attachment stays readable after it arrives; reads never extend it. */
18
+ export declare const ATTACHMENT_TTL_HOURS = 24;
19
+ /** Live attachments one member may hold at once, so a busy chat cannot fill the storage. */
20
+ export declare const MAX_LIVE_ATTACHMENTS = 50;
21
+ export declare const ATTACHMENT_SOURCES: readonly ["telegram"];
22
+ /** Who asks, as the agent's runtime knows it: the member, their chat and the agent session. */
23
+ export interface AttachmentCaller {
24
+ readonly principal: Principal;
25
+ /** The chat the attachment arrived in, such as `telegram:12345`. */
26
+ readonly conversation: string;
27
+ /**
28
+ * The agent session the request comes from. The first session that reads an attachment owns it;
29
+ * other sessions of the same chat, such as one after a reset, never see it.
30
+ */
31
+ readonly sessionId?: string;
32
+ }
33
+ export interface ReceiveAttachment {
34
+ readonly source: (typeof ATTACHMENT_SOURCES)[number];
35
+ /** Identifies the file at its source, such as the message and attachment index; retries reuse it. */
36
+ readonly sourceRef: string;
37
+ readonly filename: string;
38
+ readonly contentType: string;
39
+ readonly size: number;
40
+ readonly sha256: string;
41
+ }
42
+ export interface AttachmentView {
43
+ readonly id: string;
44
+ readonly filename: string;
45
+ readonly contentType: string;
46
+ readonly size: number;
47
+ readonly sha256: string;
48
+ readonly state: string;
49
+ readonly createdAt: string;
50
+ readonly expiresAt: string;
51
+ readonly promotion: unknown;
52
+ }
53
+ export interface PromotionTarget {
54
+ /** The entity whose action adopts the file, such as `document`. */
55
+ readonly entity: string;
56
+ /** The action, such as `upload` or `create`. */
57
+ readonly action: string;
58
+ /** The action's input without the file; the field that takes the upload is filled in. */
59
+ readonly input: Readonly<Record<string, unknown>>;
60
+ /** The input field that takes the upload, when the action has more than one. */
61
+ readonly field?: string;
62
+ /** The record the action updates, for an action that sets the file on an existing record. */
63
+ readonly recordId?: string;
64
+ }
65
+ /** Runs the promotion's action as the caller, with the upload's options. */
66
+ export type PromotionRunner = (input: {
67
+ readonly entity: string;
68
+ readonly action: string;
69
+ readonly input: Readonly<Record<string, unknown>>;
70
+ readonly options: {
71
+ readonly idempotencyKey: string;
72
+ readonly createIntent?: string;
73
+ };
74
+ }) => Effect.Effect<{
75
+ readonly result: unknown;
76
+ readonly edits: ReadonlyArray<unknown>;
77
+ }, unknown>;
78
+ /** The fields of an action's input that take an upload, with the property each one writes. */
79
+ export declare function uploadFields(input: Schema.Top): ReadonlyArray<{
80
+ readonly field: string;
81
+ readonly entity: string;
82
+ readonly property: string;
83
+ }>;
84
+ /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
85
+ export declare function attachmentRuntime(ontology: Ontology, sql: SqlClient.SqlClient, provider: BlobProvider["Service"]): {
86
+ receive: (input: ReceiveAttachment, caller: AttachmentCaller) => Effect.Effect<{
87
+ attachment: AttachmentView;
88
+ upload?: undefined;
89
+ } | {
90
+ attachment: AttachmentView;
91
+ upload: TransferGrant;
92
+ }, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
93
+ complete: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
94
+ list: (caller: AttachmentCaller) => Effect.Effect<AttachmentView[], PermissionDenied | import("effect/unstable/sql/SqlError").SqlError, never>;
95
+ read: (id: string, caller: AttachmentCaller) => Effect.Effect<{
96
+ attachment: AttachmentView;
97
+ download: TransferGrant;
98
+ }, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
99
+ discard: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
100
+ promote: (id: string, target: PromotionTarget, caller: AttachmentCaller, run: PromotionRunner) => Effect.Effect<{
101
+ attachment: AttachmentView;
102
+ deduplicated: boolean;
103
+ result: {} | null;
104
+ }, unknown, never>;
105
+ };
106
+ export type AttachmentRuntime = ReturnType<typeof attachmentRuntime>;
107
+ export type AttachmentFailure = ActionRejected | Conflict | NotFound | PermissionDenied;
108
+ /**
109
+ * Expires attachments past their deadline and removes the bytes of every expired or discarded one
110
+ * that no record adopted. A revision a promotion ticket still points at is left to the upload
111
+ * maintenance, once the ticket is canceled. Runs from the storage maintenance schedule, so files
112
+ * expire while the agent is idle.
113
+ */
114
+ export declare function maintainAttachments(sql: SqlClient.SqlClient, provider: BlobProvider["Service"], limit?: number): Effect.Effect<{
115
+ expired: number;
116
+ removed: number;
117
+ }, import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
@@ -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>;