@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.
- package/README.md +78 -0
- package/dist/entity.d.ts +53 -2
- package/dist/entity.js +31 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/runtime/batch-diagnostics.d.ts +22 -0
- package/dist/runtime/batch-diagnostics.js +89 -0
- package/dist/runtime/batch.d.ts +48 -0
- package/dist/runtime/batch.js +473 -0
- package/dist/runtime/records.d.ts +2 -2
- package/dist/runtime/records.js +25 -6
- package/dist/runtime/sql-runtime.js +125 -5
- package/dist/runtime.d.ts +51 -2
- package/dist/runtime.js +2 -1
- package/dist/service/errors.js +17 -0
- package/dist/service/mcp/server.js +11 -3
- package/dist/service/mcp/tools.d.ts +8 -1
- package/dist/service/mcp/tools.js +35 -1
- package/dist/service/member-file-routes.d.ts +22 -0
- package/dist/service/member-file-routes.js +52 -0
- package/dist/service/member-files.d.ts +165 -0
- package/dist/service/member-files.js +145 -0
- package/dist/service/schemas.js +4 -0
- package/dist/service/service.js +10 -1
- package/dist/storage/attachments.d.ts +26 -0
- package/dist/storage/attachments.js +119 -4
- package/dist/storage/neon.js +48 -0
- package/dist/storage/provider.d.ts +2 -0
- package/dist/storage/sessions.d.ts +20 -1
- package/dist/storage/sessions.js +36 -6
- package/package.json +4 -2
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/dist/service/errors.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|