@segmentstream-cloud/ontology 0.25.1 → 0.26.1

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.
@@ -1,9 +1,11 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Exit, Option } from "effect";
2
2
  import { beginReplicationWrite } from "../replication/transaction.js";
3
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
4
- import { Conflict } from "../errors.js";
4
+ import { ActionRejected, Conflict } from "../errors.js";
5
5
  import { toSnakeCase } from "../names.js";
6
6
  import { User } from "../user.js";
7
+ import { ACTION_MAX_BATCH_ROWS, BATCH_MAX_BYTES, BATCH_MAX_ROWS, batchInputBytes, executeBatch, planBatch, } from "./batch.js";
8
+ import { logBatchConflict } from "./batch-diagnostics.js";
7
9
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
8
10
  import { entityModel, ontologyModel } from "./model.js";
9
11
  import { loadRecord, notFound, readHistory, relatedCondition, runQuery, validateQuery, } from "./reads.js";
@@ -23,7 +25,7 @@ function withoutUndefined(value) {
23
25
  .map(([key, entry]) => [key, withoutUndefined(entry)]));
24
26
  }
25
27
  /** A unique violation becomes Conflict, naming the property when the index covers one column. */
26
- function uniqueConflict(entity, error) {
28
+ function uniqueConflict(entity, error, batch) {
27
29
  const cause = (error.reason.cause ?? {});
28
30
  const unique = error.reason._tag === "UniqueViolation" || cause.code === "23505";
29
31
  if (!unique) {
@@ -31,17 +33,36 @@ function uniqueConflict(entity, error) {
31
33
  }
32
34
  const columns = typeof cause.detail === "string" ? /^Key \(([^)]*)\)=/.exec(cause.detail)?.[1] : undefined;
33
35
  const field = entity.fields.find((candidate) => candidate.column === columns);
36
+ const fields = typeof columns === "string"
37
+ ? columns.split(", ").flatMap((column) => {
38
+ const candidate = entity.fields.find((entry) => entry.column === column);
39
+ return candidate === undefined ? [] : [candidate.relationship ?? candidate.name];
40
+ })
41
+ : [];
42
+ const details = batch ? { batch: { indices: [], ...(fields.length > 0 ? { fields } : {}) } } : {};
34
43
  const label = entity.label.toLowerCase();
35
- return Effect.fail(field === undefined
44
+ const conflict = field === undefined
36
45
  ? new Conflict({
37
46
  entity: entity.name,
38
47
  message: `The ${label} conflicts with an existing one.`,
48
+ ...details,
39
49
  })
40
50
  : new Conflict({
41
51
  entity: entity.name,
42
52
  property: field.name,
43
53
  message: `A ${label} with this ${field.name} already exists.`,
44
- }));
54
+ ...details,
55
+ });
56
+ if (batch) {
57
+ // Keep the driver's complete cause available to operators without serializing SQL or values.
58
+ Object.defineProperty(conflict, "cause", { value: error, enumerable: false });
59
+ return logBatchConflict(entity, error, {
60
+ operation: batch.operation,
61
+ action: batch.scope.action,
62
+ actionId: batch.scope.actionId,
63
+ }).pipe(Effect.andThen(Effect.fail(conflict)));
64
+ }
65
+ return Effect.fail(conflict);
45
66
  }
46
67
  /**
47
68
  * Platform identity bookkeeping touches only User properties and their history. User is excluded
@@ -91,6 +112,8 @@ export function makeOntologyRuntime(ontology, sql, scope) {
91
112
  run: (text, params) => client.unsafe(text, params.values),
92
113
  };
93
114
  const entityOf = (name) => Effect.sync(() => entityModel(model, name));
115
+ let attemptedBatchRows = 0;
116
+ let attributionInitialized = false;
94
117
  const write = (entityName, operation) => Effect.gen(function* () {
95
118
  const entity = yield* entityOf(entityName);
96
119
  if (scope === undefined) {
@@ -108,14 +131,111 @@ export function makeOntologyRuntime(ontology, sql, scope) {
108
131
  for (const edit of edits) {
109
132
  scope.onEdit?.(edit);
110
133
  }
134
+ if (edits.length > 0 && scope.execution !== undefined) {
135
+ attributionInitialized = true;
136
+ }
111
137
  return result;
112
138
  });
139
+ const batch = (entityName, operation, input) => Effect.suspend(() => {
140
+ const startedAt = Date.now();
141
+ let statements = 0;
142
+ let distinctReferences = 0;
143
+ let counts = { created: 0, updated: 0, unchanged: 0 };
144
+ const run = Effect.gen(function* () {
145
+ if (scope === undefined) {
146
+ return yield* Effect.die(new WriteOutsideAction({
147
+ entity: entityName,
148
+ message: `Writes to ${entityName} run inside an action, which history records.`,
149
+ }));
150
+ }
151
+ const declared = ontology.entities.find((entity) => entity.name === entityName);
152
+ if (declared?.builtIn === true || declared?.entity.definition.source !== undefined) {
153
+ return yield* new ActionRejected({
154
+ message: "Batch writes require an ordinary stored entity.",
155
+ batch: { indices: [] },
156
+ });
157
+ }
158
+ const entity = yield* entityOf(entityName);
159
+ const length = input.records.length;
160
+ if (length > 0) {
161
+ attemptedBatchRows += length;
162
+ }
163
+ if (length > BATCH_MAX_ROWS || (length > 0 && attemptedBatchRows > ACTION_MAX_BATCH_ROWS)) {
164
+ return yield* new ActionRejected({
165
+ message: length > BATCH_MAX_ROWS
166
+ ? `A batch accepts at most ${BATCH_MAX_ROWS} records.`
167
+ : `An action accepts at most ${ACTION_MAX_BATCH_ROWS} attempted batch records.`,
168
+ batch: { indices: [] },
169
+ });
170
+ }
171
+ if (batchInputBytes(input) > BATCH_MAX_BYTES) {
172
+ return yield* new ActionRejected({
173
+ message: `A batch accepts at most ${BATCH_MAX_BYTES} UTF-8 JSON bytes.`,
174
+ batch: { indices: [] },
175
+ });
176
+ }
177
+ const plan = yield* planBatch(entity, operation, input);
178
+ distinctReferences = plan.distinctReferences;
179
+ if (length === 0) {
180
+ return plan.returning === "summary" ? counts : { ...counts, items: [] };
181
+ }
182
+ const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
183
+ if (Option.isNone(enclosingTransaction)) {
184
+ return yield* Effect.die(new WriteOutsideAction({
185
+ entity: entityName,
186
+ message: "Batch writes require the enclosing action transaction.",
187
+ }));
188
+ }
189
+ const edits = [];
190
+ statements += 1; // The SQL client's nested transaction issues one savepoint.
191
+ const result = yield* sql
192
+ .withTransaction((scope.replicationInitialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(executeBatch({
193
+ ...context,
194
+ scope,
195
+ edits,
196
+ attributionInitialized,
197
+ run: (text, params) => {
198
+ statements += 1;
199
+ return context.run(text, params);
200
+ },
201
+ }, entity, plan)), Effect.onExit((exit) => Effect.sync(() => {
202
+ if (Exit.isFailure(exit)) {
203
+ statements += 1; // A failed nested transaction rolls back to its savepoint.
204
+ }
205
+ }))))
206
+ .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error, { operation, scope })));
207
+ counts = { created: result.created, updated: result.updated, unchanged: result.unchanged };
208
+ for (const edit of edits) {
209
+ scope.onEdit?.(edit);
210
+ }
211
+ if (edits.length > 0 && scope.execution !== undefined) {
212
+ attributionInitialized = true;
213
+ }
214
+ return result;
215
+ });
216
+ return run.pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.batch_completed").pipe(Effect.annotateLogs({
217
+ operation,
218
+ entity: entityName,
219
+ action: scope?.action,
220
+ actionId: scope?.actionId,
221
+ inputCount: input.records.length,
222
+ distinctReferenceCount: distinctReferences,
223
+ changedCount: counts.created + counts.updated,
224
+ unchangedCount: counts.unchanged,
225
+ sqlStatements: statements,
226
+ batchMs: Date.now() - startedAt,
227
+ outcome: Exit.isSuccess(exit) ? "success" : "failure",
228
+ }))));
229
+ });
113
230
  const query = (input) => Effect.gen(function* () {
114
231
  const entity = yield* entityOf(input.entity);
115
232
  const validated = yield* validateQuery(entity, input.query);
116
233
  return yield* orDieOnSql(runQuery(context, entity, validated));
117
234
  });
118
235
  return {
236
+ createMany: ((input) => batch(input.entity, "create", input)),
237
+ updateMany: ((input) => batch(input.entity, "update", input)),
238
+ upsertMany: ((input) => batch(input.entity, "upsert", input)),
119
239
  get: (input) => orDieOnSql(Effect.gen(function* () {
120
240
  const entity = yield* entityOf(input.entity);
121
241
  const record = yield* loadRecord(context, entity, input.id, input.forUpdate === true ? "update" : undefined);
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect } from "effect";
2
- import type { Conflict, NotFound } from "./errors.js";
2
+ import type { ActionRejected, Conflict, NotFound } from "./errors.js";
3
3
  import type { Where } from "./query.js";
4
4
  import type { WorkloadExecution } from "./runtime/workload.js";
5
5
  /** Who runs an action: an app member or a service principal, both `user` records. */
@@ -76,6 +76,51 @@ export interface UpsertResult<R = StoredRecord> {
76
76
  readonly record: R;
77
77
  readonly created: boolean;
78
78
  }
79
+ /** Whether a batch returns its records or only outcome counts. Defaults to `records`. */
80
+ export type BatchReturning = "records" | "summary";
81
+ export interface BatchOptions {
82
+ readonly returning?: BatchReturning;
83
+ }
84
+ /** Outcome counts, which together equal the number of inputs in a successful batch. */
85
+ export interface BatchSummaryResult {
86
+ readonly created: number;
87
+ readonly updated: number;
88
+ readonly unchanged: number;
89
+ }
90
+ /** One result per input, in input order, including records whose values did not change. */
91
+ export interface BatchRecordResult<R = StoredRecord> extends BatchSummaryResult {
92
+ readonly items: ReadonlyArray<{
93
+ readonly index: number;
94
+ readonly status: "created" | "updated" | "unchanged";
95
+ readonly record: R;
96
+ }>;
97
+ }
98
+ export type BatchResult<R = StoredRecord> = BatchRecordResult<R> | BatchSummaryResult;
99
+ /** Raw batch inputs use wire entity and field names. */
100
+ export interface CreateManyInput extends BatchOptions {
101
+ readonly entity: string;
102
+ readonly records: ReadonlyArray<StoredValues>;
103
+ }
104
+ export interface UpdateManyInput extends BatchOptions {
105
+ readonly entity: string;
106
+ readonly records: ReadonlyArray<{
107
+ readonly id: string;
108
+ readonly values: StoredValues;
109
+ }>;
110
+ }
111
+ export interface UpsertManyInput extends CreateManyInput {
112
+ readonly on: readonly [string, ...string[]];
113
+ }
114
+ type BatchWriteError = ActionRejected | Conflict | NotFound;
115
+ interface BatchWrite<Input extends BatchOptions> {
116
+ (input: Input & {
117
+ readonly returning: "summary";
118
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError>;
119
+ (input: Input & {
120
+ readonly returning?: "records";
121
+ }): Effect.Effect<BatchRecordResult, BatchWriteError>;
122
+ (input: Input): Effect.Effect<BatchResult, BatchWriteError>;
123
+ }
79
124
  type ReadsService = {
80
125
  readonly get: (input: {
81
126
  readonly entity: string;
@@ -88,6 +133,9 @@ type ReadsService = {
88
133
  }) => Effect.Effect<ReadonlyArray<StoredRecord>>;
89
134
  };
90
135
  type WritesService = {
136
+ readonly createMany: BatchWrite<CreateManyInput>;
137
+ readonly updateMany: BatchWrite<UpdateManyInput>;
138
+ readonly upsertMany: BatchWrite<UpsertManyInput>;
91
139
  readonly create: (input: {
92
140
  readonly entity: string;
93
141
  readonly values: StoredValues;
@@ -130,7 +178,8 @@ export declare class OntologyReads extends OntologyReads_base {
130
178
  declare const OntologyWrites_base: Context.ServiceClass<OntologyWrites, "@segmentstream-cloud/ontology/OntologyWrites", WritesService>;
131
179
  /**
132
180
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
133
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
181
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
182
+ * `prepare` never has it.
134
183
  */
135
184
  export declare class OntologyWrites extends OntologyWrites_base {
136
185
  }
package/dist/runtime.js CHANGED
@@ -8,7 +8,8 @@ export class OntologyReads extends Context.Service()("@segmentstream-cloud/ontol
8
8
  }
9
9
  /**
10
10
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
11
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
11
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
12
+ * `prepare` never has it.
12
13
  */
13
14
  export class OntologyWrites extends Context.Service()("@segmentstream-cloud/ontology/OntologyWrites") {
14
15
  }
@@ -1,6 +1,7 @@
1
1
  import { Cause, Context, Effect, Option, Predicate, Schema, SchemaIssue } from "effect";
2
2
  import { HttpServerResponse } from "effect/unstable/http";
3
3
  import { HttpApiError } from "effect/unstable/httpapi";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  /** The correlation id of the current request, in every error body and the `x-request-id` header. */
5
6
  export class RequestId extends Context.Service()("@segmentstream-cloud/ontology/RequestId") {
6
7
  }
@@ -64,6 +65,19 @@ function listed(values) {
64
65
  ? values.join("")
65
66
  : `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
66
67
  }
68
+ /** Only the declared safe locations cross a transport, even for structurally tagged failures. */
69
+ function batchDetails(error) {
70
+ const batch = error.batch;
71
+ if (!Schema.is(BatchErrorDetails)(batch)) {
72
+ return {};
73
+ }
74
+ return {
75
+ batch: {
76
+ indices: batch.indices,
77
+ ...(batch.fields === undefined ? {} : { fields: batch.fields }),
78
+ },
79
+ };
80
+ }
67
81
  export function toServerResponse(response) {
68
82
  return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
69
83
  status: response.status,
@@ -323,6 +337,7 @@ export function expectedFailure(error, requestId) {
323
337
  detail: message,
324
338
  requestId,
325
339
  issues: [{ path: [], message }],
340
+ ...batchDetails(error),
326
341
  },
327
342
  };
328
343
  }
@@ -421,6 +436,7 @@ export function expectedFailure(error, requestId) {
421
436
  requestId,
422
437
  ...(typeof entity === "string" ? { entity } : {}),
423
438
  ...(typeof id === "string" ? { id } : {}),
439
+ ...batchDetails(error),
424
440
  },
425
441
  };
426
442
  }
@@ -434,6 +450,7 @@ export function expectedFailure(error, requestId) {
434
450
  requestId,
435
451
  ...(typeof entity === "string" ? { entity } : {}),
436
452
  ...(typeof property === "string" ? { property } : {}),
453
+ ...batchDetails(error),
437
454
  },
438
455
  };
439
456
  }
@@ -17,6 +17,7 @@ import { OntologyBackend } from "../runtime-contract.js";
17
17
  import { runtimeCredential } from "../runtime-identity.js";
18
18
  import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
19
19
  import { uploadFields } from "../../storage/attachments.js";
20
+ import { memberFileOperations } from "../member-files.js";
20
21
  import { ACTION_OPTIONS_ARGUMENT, mcpTools, visibleTools } from "./tools.js";
21
22
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
22
23
  export const MCP_PATH = "/mcp";
@@ -169,7 +170,7 @@ function toolFailure(cause, requestId) {
169
170
  isError: true,
170
171
  }));
171
172
  }
172
- function runTool({ built, operations, aggregate, conversations }, tool, args, request) {
173
+ function runTool({ built, operations, aggregate, conversations, memberFiles }, tool, args, request) {
173
174
  return Effect.gen(function* () {
174
175
  const { target } = tool;
175
176
  switch (target.kind) {
@@ -197,6 +198,8 @@ function runTool({ built, operations, aggregate, conversations }, tool, args, re
197
198
  const { id } = yield* decodeArguments(GetArguments, args);
198
199
  return structured(yield* encoded(schemas.record, yield* operations.get(target.planned, id)));
199
200
  }
201
+ case "member-file":
202
+ return structured(yield* memberFiles(target.operation, args, identityToken(request)));
200
203
  case "conversation-search": {
201
204
  const search = yield* decodeArguments(ConversationSearch, args);
202
205
  const principal = yield* CurrentPrincipal;
@@ -463,14 +466,19 @@ function handlePost(server, verifyToken, verifyRuntime, backend) {
463
466
  * authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
464
467
  */
465
468
  export function mcpLayer(options) {
466
- const tools = mcpTools(options.built, options.conversations !== undefined);
467
469
  return Layer.effectDiscard(Effect.gen(function* () {
468
470
  const router = yield* HttpRouter.HttpRouter;
469
471
  const backend = yield* OntologyBackend;
472
+ const tools = mcpTools(options.built, options.conversations !== undefined, {
473
+ attachments: backend.attachments !== undefined,
474
+ files: backend.blobs !== undefined,
475
+ });
476
+ const operations = makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
470
477
  const server = {
471
478
  built: options.built,
472
479
  tools,
473
- operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
480
+ operations,
481
+ memberFiles: memberFileOperations(backend, options.built.plan, operations),
474
482
  aggregate: options.aggregates.model,
475
483
  conversations: options.conversations,
476
484
  };
@@ -1,5 +1,6 @@
1
1
  import type { JsonSchemaObject } from "../../analyze.js";
2
2
  import { type PermissionHolder } from "../../permissions.js";
3
+ import { type MemberFileOperation } from "../member-files.js";
3
4
  import type { BuiltApi } from "../api.js";
4
5
  import type { PlannedAction, PlannedEntity } from "../plan.js";
5
6
  /** How a tool behaves, as MCP's tool annotations say it. */
@@ -28,6 +29,9 @@ export type McpToolTarget = {
28
29
  readonly kind: "aggregate";
29
30
  } | {
30
31
  readonly kind: "conversation-search";
32
+ } | {
33
+ readonly kind: "member-file";
34
+ readonly operation: MemberFileOperation;
31
35
  };
32
36
  /** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
33
37
  export interface McpTool {
@@ -60,7 +64,10 @@ export declare const ACTION_OPTIONS_ARGUMENT = "_options";
60
64
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
61
65
  * `check` already refuses.
62
66
  */
63
- export declare function mcpTools(built: BuiltApi, conversations?: boolean): ReadonlyArray<McpTool>;
67
+ export declare function mcpTools(built: BuiltApi, conversations?: boolean, storage?: {
68
+ attachments?: boolean;
69
+ files?: boolean;
70
+ }): ReadonlyArray<McpTool>;
64
71
  /**
65
72
  * The tools a caller may see: those whose every permission the caller's roles grant, each with the
66
73
  * caller's input schema.
@@ -8,6 +8,7 @@ import { queryJsonSchema } from "../../query.js";
8
8
  import { measureDescription, measuresFor, modelMeasureDescription, modelMeasuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
9
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
10
10
  import { uploadFields } from "../../storage/attachments.js";
11
+ import { MEMBER_FILE_SCHEMAS } from "../member-files.js";
11
12
  import { actionDescription, requires } from "../descriptions.js";
12
13
  import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, HistoryPage, MAXIMUM_TIMEZONE_LENGTH, Me, } from "../schemas.js";
13
14
  const DEFS = "#/$defs/";
@@ -424,7 +425,7 @@ function aggregateTool(manifest) {
424
425
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
425
426
  * `check` already refuses.
426
427
  */
427
- export function mcpTools(built, conversations = false) {
428
+ export function mcpTools(built, conversations = false, storage = {}) {
428
429
  const { manifest } = built.plan.ontology;
429
430
  const tools = [
430
431
  ...GLOBAL_TOOLS,
@@ -482,6 +483,39 @@ export function mcpTools(built, conversations = false) {
482
483
  target: { kind: "conversation-search" },
483
484
  });
484
485
  }
486
+ const descriptions = {
487
+ "attachments-list": "List chat attachments by conversation (provider:chat ID), sender member UUID and ISO time range. " +
488
+ "Returns a bounded page and nextCursor; use before to resume. Includes expired metadata. " +
489
+ "Administrators with agent_chat:update may access any workspace chat; active group participants " +
490
+ "and DM senders may access their files.",
491
+ "attachments-get": "Read metadata of an accessible chat attachment. Expired or purged attachments return a clear error.",
492
+ "attachments-download": "Download an accessible chat attachment. Returns filename, content type, size, SHA-256 and a " +
493
+ "short-lived GET grant. Stream the URL's bytes with its supplied headers before expiry; do not " +
494
+ "forward your app bearer token.",
495
+ "attachments-promote": "Keep a chat attachment through an ontology action such as document.upload. Supply the action " +
496
+ "input without the file; the platform adopts the stored revision, fills SHA-256 when declared, " +
497
+ "and enforces your current action permissions. Retries reuse the promotion result.",
498
+ "files-download": "Download a stored blob property of a record you may read. property defaults to content. Returns " +
499
+ "immutable revision metadata, filename, content type, size, SHA-256 and a short-lived GET grant. " +
500
+ "Stream bytes from the URL with its supplied headers before expiry; do not forward your app " +
501
+ "bearer token.",
502
+ };
503
+ for (const operation of Object.keys(MEMBER_FILE_SCHEMAS)) {
504
+ if (operation === "files-download" ? !storage.files : !storage.attachments) {
505
+ continue;
506
+ }
507
+ tools.push({
508
+ name: operation.replaceAll("-", "_"),
509
+ title: operation.replaceAll("-", " "),
510
+ description: descriptions[operation],
511
+ inputSchema: encodedJsonSchema(MEMBER_FILE_SCHEMAS[operation]),
512
+ annotations: operation === "attachments-promote"
513
+ ? { readOnlyHint: false, idempotentHint: true, openWorldHint: true }
514
+ : READ,
515
+ permissions: [],
516
+ target: { kind: "member-file", operation },
517
+ });
518
+ }
485
519
  const seen = new Set();
486
520
  const duplicates = tools.filter(({ name }) => seen.size === seen.add(name).size);
487
521
  if (duplicates.length > 0) {
@@ -0,0 +1,22 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { type SessionExchange, type TokenVerifier } from "./authentication.js";
4
+ import type { AgentChannelFactory } from "./agent/channel.js";
5
+ import type { AfterCommitHook } from "./config.js";
6
+ import type { ConnectionTransportFactory } from "./connections.js";
7
+ import type { ApiPlan } from "./plan.js";
8
+ import type { ReplicationKick } from "./replication.js";
9
+ import { RequestId } from "./errors.js";
10
+ import { OntologyBackend } from "./runtime-contract.js";
11
+ interface MemberFileRoutesOptions {
12
+ readonly plan: ApiPlan;
13
+ readonly hooks: ReadonlyMap<string, AfterCommitHook>;
14
+ readonly connections: ConnectionTransportFactory;
15
+ readonly replicate: ReplicationKick;
16
+ readonly agentChannel?: AgentChannelFactory | undefined;
17
+ readonly verifyToken: TokenVerifier;
18
+ readonly exchangeSession?: SessionExchange | undefined;
19
+ }
20
+ /** Authenticated users use these routes; the agent's receive/discard routes stay runtime-owned. */
21
+ export declare function memberFileRoutes(options: MemberFileRoutesOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
22
+ export {};
@@ -0,0 +1,52 @@
1
+ import { Cause, Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { withPrincipal } from "./authentication.js";
5
+ import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
6
+ import { identityToken, makeOperations } from "./operations.js";
7
+ import { memberFileOperations } from "./member-files.js";
8
+ import { OntologyBackend } from "./runtime-contract.js";
9
+ /** Authenticated users use these routes; the agent's receive/discard routes stay runtime-owned. */
10
+ export function memberFileRoutes(options) {
11
+ return Layer.effectDiscard(Effect.gen(function* () {
12
+ const router = yield* HttpRouter.HttpRouter;
13
+ const backend = yield* OntologyBackend;
14
+ const run = memberFileOperations(backend, options.plan, makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel));
15
+ const route = (kind) => (request) => Effect.gen(function* () {
16
+ const bearer = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request));
17
+ const operation = Effect.gen(function* () {
18
+ const params = yield* HttpRouter.params;
19
+ let args = params;
20
+ if (kind === "attachments-list") {
21
+ const query = Object.fromEntries(new URL(request.url, "https://app.invalid").searchParams);
22
+ args = {
23
+ ...query,
24
+ ...(query.limit === undefined ? {} : { limit: Number(query.limit) }),
25
+ };
26
+ }
27
+ else if (kind === "attachments-promote") {
28
+ const body = yield* request.json.pipe(Effect.mapError(() => new InvalidRequestBody({ reason: "invalid_json", issues: [] })));
29
+ args =
30
+ typeof body === "object" && body !== null && !Array.isArray(body)
31
+ ? { ...body, id: params.id }
32
+ : body;
33
+ }
34
+ const answer = yield* run(kind, args, identityToken(request));
35
+ return HttpServerResponse.jsonUnsafe(answer, {
36
+ headers: { "cache-control": "private, no-store" },
37
+ });
38
+ }).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
39
+ ? Effect.failCause(cause)
40
+ : Effect.flatMap(RequestId, (id) => failureResponse(cause, id))));
41
+ return yield* withPrincipal(bearer, options.verifyToken, backend, operation, {
42
+ session: { request, exchange: options.exchangeSession },
43
+ });
44
+ });
45
+ yield* router.add("GET", "/api/ontology/attachments", route("attachments-list"));
46
+ yield* router.add("GET", "/api/ontology/attachments/:id", route("attachments-get"));
47
+ yield* router.add("GET", "/api/ontology/attachments/:id/download", route("attachments-download"));
48
+ yield* router.add("POST", "/api/ontology/attachments/:id/promote", route("attachments-promote"));
49
+ yield* router.add("GET", "/api/ontology/files/:entity/:id/:property", route("files-download"));
50
+ yield* router.add("GET", "/api/ontology/files/:entity/:id", route("files-download"));
51
+ }));
52
+ }