@protobase/server 0.51.2 → 0.52.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.
- package/dist/aggregate-groups.js +2 -2
- package/dist/aggregate-service.js +4 -4
- package/dist/aggregate-splits.js +1 -1
- package/dist/assistant/batch-approval.js +49 -51
- package/dist/assistant/batch-before.js +38 -0
- package/dist/assistant/batch-echo.js +36 -0
- package/dist/assistant/batch-write-args.js +14 -5
- package/dist/assistant/batch-write-tool.js +30 -11
- package/dist/assistant/budgeted-page.js +30 -0
- package/dist/assistant/comment-tools.d.ts +20 -0
- package/dist/assistant/comment-tools.js +120 -0
- package/dist/assistant/count-records-tool.d.ts +5 -0
- package/dist/assistant/count-records-tool.js +84 -0
- package/dist/assistant/query-result-budget.js +32 -0
- package/dist/assistant/query-tools.js +7 -7
- package/dist/assistant/record-before.js +2 -1
- package/dist/assistant/record-field-piece.js +54 -0
- package/dist/assistant/record-tool-args.js +6 -3
- package/dist/assistant/record-tools.d.ts +4 -1
- package/dist/assistant/record-tools.js +17 -6
- package/dist/assistant/records.d.ts +19 -0
- package/dist/assistant/records.js +20 -13
- package/dist/assistant/result-budget.js +72 -0
- package/dist/assistant/tools.d.ts +3 -0
- package/dist/assistant/write-echo.js +41 -0
- package/dist/assistant/write-messages.js +26 -6
- package/dist/assistant/write-prompts.js +37 -12
- package/dist/assistant/write-record-tools.js +15 -6
- package/dist/batch/run-batch.js +1 -1
- package/dist/better-auth/auth-settings.d.ts +1 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/resource-change.d.ts +4 -1
- package/dist/changes/resource-change.js +27 -3
- package/dist/comments/post-comment.js +1 -1
- package/dist/comments/read-comments.js +1 -1
- package/dist/comments/tool-comment-types.d.ts +28 -0
- package/dist/comments/tool-comments.js +30 -0
- package/dist/create-admin.d.ts +5 -2
- package/dist/create-admin.js +20 -10
- package/dist/deps.d.ts +1 -1
- package/dist/encryption/scan.js +1 -1
- package/dist/error-handler.js +3 -2
- package/dist/etag.js +1 -1
- package/dist/files/download.js +1 -1
- package/dist/files/file-cleanup.d.ts +1 -1
- package/dist/functions/records.d.ts +1 -1
- package/dist/functions/records.js +2 -2
- package/dist/history/read-history.js +2 -2
- package/dist/index.d.ts +9 -3
- package/dist/index.js +9 -4
- package/dist/jobs/run-job.d.ts +1 -1
- package/dist/list-photos.js +2 -2
- package/dist/list-service.js +6 -6
- package/dist/map-service.js +2 -2
- package/dist/mcp/mcp-context.d.ts +7 -4
- package/dist/mcp/mcp-context.js +6 -4
- package/dist/mcp/mcp-server.d.ts +3 -2
- package/dist/mcp/mcp-tools.d.ts +6 -1
- package/dist/mcp/mcp-tools.js +8 -4
- package/dist/package.js +1 -1
- package/dist/read-service.js +2 -2
- package/dist/realtime/broadcast-api.js +3 -3
- package/dist/realtime/broadcast-frame.js +16 -8
- package/dist/realtime/broadcast-hub.js +16 -6
- package/dist/realtime/broadcast-request.js +1 -1
- package/dist/realtime/change-checker.js +1 -1
- package/dist/realtime/channel-access.js +1 -1
- package/dist/realtime/database-broadcasts.js +83 -0
- package/dist/realtime/presence-hub.js +74 -39
- package/dist/realtime/realtime-app.js +39 -3
- package/dist/realtime/realtime-routes.js +12 -9
- package/dist/realtime/realtime-socket.js +10 -7
- package/dist/realtime/relay/frame-cursor.js +38 -0
- package/dist/realtime/relay/presence-replica.js +57 -0
- package/dist/realtime/relay/realtime-options.d.ts +13 -0
- package/dist/realtime/relay/realtime-options.js +9 -0
- package/dist/realtime/relay/realtime-relay.js +104 -0
- package/dist/realtime/relay/relay-frame-sql.js +82 -0
- package/dist/realtime/relay/relay-presence-sql.js +71 -0
- package/dist/realtime/relay/relay-schema-sql.js +55 -0
- package/dist/realtime/relay/relay-schema.d.ts +7 -0
- package/dist/realtime/relay/relay-schema.js +18 -0
- package/dist/realtime/relay/relayed-hubs.js +61 -0
- package/dist/records.js +1 -1
- package/dist/related-labels.js +1 -1
- package/dist/rest/rest-context.js +1 -1
- package/dist/routes/batch.js +1 -1
- package/dist/routes/collection.js +2 -2
- package/dist/routes/record.js +3 -3
- package/dist/row-permissions.js +1 -1
- package/dist/sql-parts.js +1 -1
- package/dist/web-app/mcp-icons.d.ts +9 -0
- package/dist/web-app/mcp-icons.js +26 -0
- package/dist/webhooks/subscriber-view.js +27 -2
- package/dist/webhooks/webhooks.js +41 -12
- package/dist/write-pipeline.js +1 -1
- package/dist/write-shared.js +1 -1
- package/package.json +4 -4
package/dist/aggregate-groups.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { badRequest } from "./problem.js";
|
|
2
|
-
import { readTransaction } from "./transactions.js";
|
|
3
|
-
import { parseParams } from "./params.js";
|
|
4
2
|
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
3
|
+
import { readTransaction } from "./transactions.js";
|
|
5
4
|
import { splitGroups } from "./aggregate-splits.js";
|
|
5
|
+
import { parseParams } from "./params.js";
|
|
6
6
|
import { scoped, seriesRange } from "./aggregate-service.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { groupTotals, series } from "@protobase/query";
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { badRequest } from "./problem.js";
|
|
2
2
|
import { checkedFilter } from "./filter-input.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
|
-
import { readTransaction } from "./transactions.js";
|
|
5
|
-
import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
6
|
-
import { parseParams } from "./params.js";
|
|
7
4
|
import { readScope } from "./read-scope.js";
|
|
8
5
|
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
6
|
+
import { readTransaction } from "./transactions.js";
|
|
7
|
+
import { parseParams } from "./params.js";
|
|
9
8
|
import { rangeEnd, rangeStart } from "./range-ends.js";
|
|
9
|
+
import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { sql } from "kysely";
|
|
12
11
|
import { facetCounts, histogram, series, tenantCondition, withoutField } from "@protobase/query";
|
|
12
|
+
import { sql } from "kysely";
|
|
13
13
|
//#region src/aggregate-service.ts
|
|
14
14
|
const field = z.string().min(1);
|
|
15
15
|
const filter = z.string().optional();
|
package/dist/aggregate-splits.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readTransaction } from "./transactions.js";
|
|
2
1
|
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
2
|
+
import { readTransaction } from "./transactions.js";
|
|
3
3
|
import { series, splitSeries } from "@protobase/query";
|
|
4
4
|
//#region src/aggregate-splits.ts
|
|
5
5
|
/**
|
|
@@ -1,59 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
import { createChange, recordName, resourceLabel, shown, updateChange } from "./write-prompts.js";
|
|
2
|
+
const isRef = (value) => typeof value === "object" && value !== null && typeof value.$ref === "string";
|
|
3
|
+
const refOfKey = (key) => Array.isArray(key) ? key.find(isRef) : isRef(key) ? key : void 0;
|
|
4
|
+
const joined = (fields, none) => fields.length > 0 ? fields.map(({ label, value }) => `${label}: ${value}`).join("; ") : none;
|
|
5
|
+
/** The approval of `operations` as one transaction. */
|
|
6
|
+
const batchApproval = ({ text, described, operations, before, softDeletes }) => {
|
|
7
|
+
const creates = new Map(operations.flatMap((operation, index) => operation.op === "create" && operation.ref ? [[operation.ref, {
|
|
8
|
+
index,
|
|
9
|
+
resource: operation.resource
|
|
10
|
+
}]] : []));
|
|
11
|
+
const refName = ({ $ref }) => {
|
|
12
|
+
const created = creates.get($ref);
|
|
13
|
+
return created ? text.fromWrite(resourceLabel(described(created.resource)), created.index + 1) : `"${shown($ref)}"`;
|
|
14
|
+
};
|
|
15
|
+
const dataOf = (data) => Object.fromEntries(Object.entries(data).map(([name, value]) => [name, isRef(value) ? refName(value) : value]));
|
|
16
|
+
const nameOf = (operation, index) => {
|
|
17
|
+
const ref = refOfKey(operation.key);
|
|
18
|
+
return ref ? refName(ref) : recordName(described(operation.resource), operation.key, before.get(index) ?? {});
|
|
19
|
+
};
|
|
20
|
+
const entryOf = (operation, index) => {
|
|
21
|
+
const resource = described(operation.resource);
|
|
22
|
+
const label = (action) => `${index + 1}. ${action}`;
|
|
23
|
+
if (operation.op === "create") {
|
|
24
|
+
const { action, fields } = createChange(text, resource, dataOf(operation.data));
|
|
25
|
+
return {
|
|
26
|
+
label: label(action),
|
|
27
|
+
value: joined(fields, text.noFields)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (operation.op === "update") {
|
|
31
|
+
const { action, fields } = updateChange(text, resource, nameOf(operation, index), before.get(index) ?? {}, dataOf(operation.data));
|
|
32
|
+
return {
|
|
33
|
+
label: label(action),
|
|
34
|
+
value: joined(fields, text.noChanges)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
label: label(text.deleteAction(nameOf(operation, index))),
|
|
39
|
+
value: softDeletes.has(operation.resource) ? text.restorable : text.permanent
|
|
29
40
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return [...counts];
|
|
34
|
-
};
|
|
35
|
-
const capped = (lines) => lines.length > shownResources ? [...lines.slice(0, shownResources), `and ${plural(lines.length - shownResources, "more resource")}`] : lines;
|
|
36
|
-
/**
|
|
37
|
-
* The approval of `operations`; `softDeletes` names the resources whose deletes can be restored, and a delete on any
|
|
38
|
-
* other is said to be permanent.
|
|
39
|
-
*/
|
|
40
|
-
const batchApproval = (operations, softDeletes) => {
|
|
41
|
-
const counts = countsOf(operations);
|
|
42
|
-
const permanent = counts.filter(([resource, { delete: deletes }]) => deletes > 0 && !softDeletes.has(resource));
|
|
43
|
-
const lasting = permanent.reduce((sum, [, { delete: deletes }]) => sum + deletes, 0);
|
|
44
|
-
const warning = lasting > 0 ? [`${plural(lasting, "delete")} cannot be undone:`, ...capped(permanent.map(([resource, { delete: deletes }]) => `${plural(deletes, "delete")} in ${quoted(resource, nameLength)}`))] : [];
|
|
45
|
-
const summary = capped(counts.map(([resource, count]) => `${quoted(resource, nameLength)}: ${plural(count.create, "create")}, ${plural(count.update, "update")}, ${plural(count.delete, "delete")}`));
|
|
41
|
+
};
|
|
42
|
+
const permanent = operations.filter((operation) => operation.op === "delete" && !softDeletes.has(operation.resource)).length;
|
|
46
43
|
const rest = operations.length - 50;
|
|
47
|
-
const lines = operations.slice(0, 50).map(lineOf);
|
|
48
44
|
return {
|
|
49
|
-
title:
|
|
45
|
+
title: text.batchTitle(operations.length),
|
|
50
46
|
body: [
|
|
51
|
-
...
|
|
52
|
-
|
|
53
|
-
...
|
|
47
|
+
...permanent > 0 ? [text.permanentDeletes(permanent)] : [],
|
|
48
|
+
text.allOrNone,
|
|
49
|
+
...rest > 0 ? [text.moreWrites(rest)] : []
|
|
54
50
|
].join("\n"),
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
fields: operations.slice(0, 50).map(entryOf),
|
|
52
|
+
tone: permanent > 0 ? "danger" : "warning",
|
|
53
|
+
approveLabel: text.writeAll,
|
|
54
|
+
rejectLabel: text.reject
|
|
57
55
|
};
|
|
58
56
|
};
|
|
59
57
|
//#endregion
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HttpProblem, preconditionFailed } from "../problem.js";
|
|
2
|
+
import { ifMatchSatisfied } from "../etag.js";
|
|
3
|
+
import { keyOf } from "./record-tool-args.js";
|
|
4
|
+
import { hidden } from "./record-before.js";
|
|
5
|
+
//#region src/assistant/batch-before.ts
|
|
6
|
+
const atOperation = (error, index, { op, resource }) => error instanceof HttpProblem ? new HttpProblem(error.status, error.slug, error.title, error.detail, {
|
|
7
|
+
...error.extras,
|
|
8
|
+
operation: index,
|
|
9
|
+
op,
|
|
10
|
+
resource
|
|
11
|
+
}) : error;
|
|
12
|
+
const recordAt = async (context, operation, key, index) => {
|
|
13
|
+
const current = await context.records.get(operation.resource, key).catch((error) => {
|
|
14
|
+
if (hidden(error)) return void 0;
|
|
15
|
+
throw atOperation(error, index, operation);
|
|
16
|
+
});
|
|
17
|
+
if (current && operation.etag && !ifMatchSatisfied(operation.etag, current.etag)) throw atOperation(preconditionFailed("The record changed since it was read; fetch it again"), index, operation);
|
|
18
|
+
return current?.record;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The records the first `count` operations update or delete, by index, as the caller reads them now, for the approval
|
|
22
|
+
* to name and show what changes; one they may not read, or whose key points at a create in the batch, is left out. A
|
|
23
|
+
* record changed since its operation's etag rejects before asking, as the batch would fail after the user approved;
|
|
24
|
+
* so does any other refusal of the read, naming the operation.
|
|
25
|
+
*/
|
|
26
|
+
const batchBefore = async (context, operations, count) => {
|
|
27
|
+
const before = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const [index, operation] of operations.slice(0, count).entries()) {
|
|
29
|
+
if (operation.op === "create") continue;
|
|
30
|
+
const key = keyOf(operation.key);
|
|
31
|
+
if (key === void 0) continue;
|
|
32
|
+
const record = await recordAt(context, operation, key, index);
|
|
33
|
+
if (record) before.set(index, record);
|
|
34
|
+
}
|
|
35
|
+
return before;
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { batchBefore };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { keyOf } from "./record-tool-args.js";
|
|
3
|
+
import { writeEcho } from "./write-echo.js";
|
|
4
|
+
//#region src/assistant/batch-echo.ts
|
|
5
|
+
const unread = (error) => {
|
|
6
|
+
if (error instanceof HttpProblem && error.status < 500) return void 0;
|
|
7
|
+
throw error;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Each update's record as the caller reads it before the batch, for the read-only fields the batch changes; undefined
|
|
11
|
+
* for a create, a delete, and an update of a record created earlier in the batch, whose key is a `$ref`.
|
|
12
|
+
*/
|
|
13
|
+
const recordsBefore = async (context, operations) => {
|
|
14
|
+
const befores = [];
|
|
15
|
+
for (const operation of operations) {
|
|
16
|
+
const key = operation.op === "update" ? keyOf(operation.key) : void 0;
|
|
17
|
+
befores.push(key === void 0 ? void 0 : await context.records.get(operation.resource, key).then((current) => current.record, unread));
|
|
18
|
+
}
|
|
19
|
+
return befores;
|
|
20
|
+
};
|
|
21
|
+
/** The batch's results, each create's and update's record cut to its key, etag, written and changed read-only fields, and `fields`. */
|
|
22
|
+
const batchEcho = async (context, operations, results, befores, fields) => {
|
|
23
|
+
const { models } = await context.resources();
|
|
24
|
+
return results.map((result, index) => {
|
|
25
|
+
if (result.op !== "create" && result.op !== "update") return result;
|
|
26
|
+
const operation = operations[index];
|
|
27
|
+
const { record: _record, etag: _etag, ...rest } = result;
|
|
28
|
+
const model = models.find((candidate) => candidate.name === result.resource);
|
|
29
|
+
return {
|
|
30
|
+
...rest,
|
|
31
|
+
...writeEcho(model, result, operation.data, befores[index], fields[index])
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { batchEcho, recordsBefore };
|
|
@@ -1,22 +1,26 @@
|
|
|
1
|
+
import { echoFieldsOf } from "./write-echo.js";
|
|
1
2
|
//#region src/assistant/batch-write-args.ts
|
|
2
3
|
/**
|
|
3
4
|
* The `batch_write` tool's arguments, checked before anything is asked or written: only creates, updates and deletes,
|
|
4
5
|
* only the fields each takes, an update's ETag always given, and no more operations than the REST batch takes. A
|
|
5
|
-
* delete's ETag passes exactly as sent, so one left out stays left out.
|
|
6
|
+
* delete's ETag passes exactly as sent, so one left out stays left out. A create's or update's `fields`, what to give
|
|
7
|
+
* back of its record, comes apart from the operation the REST batch runs.
|
|
6
8
|
*/
|
|
7
9
|
const fieldsOf = {
|
|
8
10
|
create: [
|
|
9
11
|
"op",
|
|
10
12
|
"resource",
|
|
11
13
|
"data",
|
|
12
|
-
"ref"
|
|
14
|
+
"ref",
|
|
15
|
+
"fields"
|
|
13
16
|
],
|
|
14
17
|
update: [
|
|
15
18
|
"op",
|
|
16
19
|
"resource",
|
|
17
20
|
"key",
|
|
18
21
|
"etag",
|
|
19
|
-
"data"
|
|
22
|
+
"data",
|
|
23
|
+
"fields"
|
|
20
24
|
],
|
|
21
25
|
delete: [
|
|
22
26
|
"op",
|
|
@@ -39,12 +43,13 @@ const problemOf = (value, index) => {
|
|
|
39
43
|
if (unknown.length > 0) return `${at} has fields a ${op} does not take: ${unknown.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
40
44
|
if (!isText(value.resource)) return `${at} needs the resource's name`;
|
|
41
45
|
if (op !== "delete" && !isObject(value.data)) return `${at} needs its data as an object of field names to values`;
|
|
46
|
+
if (op !== "delete" && !echoFieldsOf(value.fields)) return `${at} has fields that are not a list of field names`;
|
|
42
47
|
if (op === "create") return value.ref === void 0 || isText(value.ref) ? void 0 : `${at} has a ref that is not a name`;
|
|
43
48
|
if (!isKey(value.key)) return `${at} needs the record's key: a string, a number, a list of them, or { "$ref": "<name>" }`;
|
|
44
49
|
if (op === "update" && !isText(value.etag)) return `${at} needs the etag the record was read with, or "*" to update any version`;
|
|
45
50
|
if (op === "delete" && "etag" in value && typeof value.etag !== "string") return `${at} has an etag that is not a string`;
|
|
46
51
|
};
|
|
47
|
-
/** The operations to run, or why the batch is refused, naming the operation at fault. */
|
|
52
|
+
/** The operations to run, each with the fields to give back of its record, or why the batch is refused, naming the operation at fault. */
|
|
48
53
|
const batchOperationsOf = (args) => {
|
|
49
54
|
const unknown = Object.keys(args).filter((name) => name !== "operations");
|
|
50
55
|
if (unknown.length > 0) return { problem: `The batch takes only "operations", not ${unknown.map((name) => JSON.stringify(name)).join(", ")}` };
|
|
@@ -57,7 +62,11 @@ const batchOperationsOf = (args) => {
|
|
|
57
62
|
}
|
|
58
63
|
const refs = operations.flatMap((operation) => operation.ref === void 0 ? [] : [operation.ref]);
|
|
59
64
|
if (new Set(refs).size !== refs.length) return { problem: "Each create may use a ref name once" };
|
|
60
|
-
|
|
65
|
+
const fields = operations.map((operation) => echoFieldsOf(operation.fields));
|
|
66
|
+
return {
|
|
67
|
+
operations: operations.map(({ fields: _fields, ...operation }) => operation),
|
|
68
|
+
fields
|
|
69
|
+
};
|
|
61
70
|
};
|
|
62
71
|
//#endregion
|
|
63
72
|
export { batchOperationsOf };
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { forcedJson } from "./result-budget.js";
|
|
2
3
|
import { defineTool } from "./tools.js";
|
|
4
|
+
import { cutRecordNote } from "./budgeted-page.js";
|
|
5
|
+
import { writeText } from "./write-messages.js";
|
|
6
|
+
import { describedResource } from "./write-prompts.js";
|
|
7
|
+
import { fieldsParameter } from "./write-echo.js";
|
|
3
8
|
import { batchApproval } from "./batch-approval.js";
|
|
9
|
+
import { batchBefore } from "./batch-before.js";
|
|
10
|
+
import { batchEcho, recordsBefore } from "./batch-echo.js";
|
|
4
11
|
import { batchOperationsOf } from "./batch-write-args.js";
|
|
5
12
|
//#region src/assistant/batch-write-tool.ts
|
|
6
13
|
/**
|
|
7
14
|
* `batch_write`: many creates, updates and deletes across resources in one transaction, through the REST API's batch
|
|
8
|
-
* write, after the user approves the batch once. Over MCP the tool runs again from
|
|
9
|
-
* everything before `approve` only checks the arguments and
|
|
15
|
+
* write, after the user approves the batch once, seeing each write in their language. Over MCP the tool runs again from
|
|
16
|
+
* the start with the user's answer, so everything before `approve` only checks the arguments and reads what the approval
|
|
17
|
+
* names: nothing is written.
|
|
10
18
|
*/
|
|
11
19
|
const writes = {
|
|
12
20
|
readOnlyHint: false,
|
|
@@ -44,10 +52,17 @@ const softDeleteSet = async (context, operations) => {
|
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
const approvalOf = async (context, operations) => {
|
|
56
|
+
const before = await batchBefore(context, operations, 50).catch(refused);
|
|
57
|
+
if (typeof before === "string") return before;
|
|
58
|
+
const readable = await context.resources();
|
|
59
|
+
return batchApproval({
|
|
60
|
+
text: writeText(context.locale ?? "en"),
|
|
61
|
+
described: (resource) => describedResource(readable, resource),
|
|
62
|
+
operations,
|
|
63
|
+
before,
|
|
64
|
+
softDeletes: new Set(readable.models.filter((model) => model.softDelete).map((model) => model.name))
|
|
65
|
+
});
|
|
51
66
|
};
|
|
52
67
|
const operationsParameter = (input) => ({
|
|
53
68
|
type: "array",
|
|
@@ -84,14 +99,15 @@ const operationsParameter = (input) => ({
|
|
|
84
99
|
ref: {
|
|
85
100
|
type: "string",
|
|
86
101
|
description: "A create's name, for later operations to point at."
|
|
87
|
-
}
|
|
102
|
+
},
|
|
103
|
+
fields: fieldsParameter
|
|
88
104
|
}
|
|
89
105
|
}
|
|
90
106
|
});
|
|
91
107
|
/** Runs many writes in one transaction once the user approves them, under the caller's access rules. */
|
|
92
108
|
const batchWriteTool = (input = {}) => defineTool({
|
|
93
109
|
name: "batch_write",
|
|
94
|
-
description: `Creates, updates and deletes records across resources in one transaction: either all are written or none, and an update or delete with an etag fails the batch when its record changed since. Returns { results }
|
|
110
|
+
description: `Creates, updates and deletes records across resources in one transaction: either all are written or none, and an update or delete with an etag fails the batch when its record changed since. Returns { results }: each created or updated record's key, new etag and, in record, the fields written and the read-only fields the batch changed. The user approves the batch in their client first; nothing is written without it.${input.resources ? ` It writes only ${input.resources.join(", ")}.` : ""}`,
|
|
95
111
|
parameters: {
|
|
96
112
|
type: "object",
|
|
97
113
|
properties: { operations: operationsParameter(input) },
|
|
@@ -101,14 +117,17 @@ const batchWriteTool = (input = {}) => defineTool({
|
|
|
101
117
|
run: async (args, context) => {
|
|
102
118
|
const parsed = batchOperationsOf(args);
|
|
103
119
|
if ("problem" in parsed) return refusal("invalid-argument", `${parsed.problem}; nothing was written.`);
|
|
104
|
-
const { operations } = parsed;
|
|
120
|
+
const { operations, fields } = parsed;
|
|
105
121
|
const outsideRefusal = outside(input, operations);
|
|
106
122
|
if (outsideRefusal) return outsideRefusal;
|
|
107
123
|
const softDeleteRefusal = await softDeleteSet(context, operations).catch(refused);
|
|
108
124
|
if (softDeleteRefusal) return softDeleteRefusal;
|
|
109
|
-
const
|
|
125
|
+
const approval = await approvalOf(context, operations);
|
|
126
|
+
if (typeof approval === "string") return approval;
|
|
127
|
+
const answer = await context.approve(approval);
|
|
110
128
|
if (answer !== "approved") return `The user did not approve this batch (${answer}); nothing was written.`;
|
|
111
|
-
|
|
129
|
+
const befores = await recordsBefore(context, operations);
|
|
130
|
+
return context.records.batch(operations).then(async (results) => forcedJson({ results: await batchEcho(context, operations, results, befores, fields) }, cutRecordNote), refused);
|
|
112
131
|
}
|
|
113
132
|
});
|
|
114
133
|
//#endregion
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { budgetedJson, forcedJson, leadingFit } from "./result-budget.js";
|
|
2
|
+
//#region src/assistant/budgeted-page.ts
|
|
3
|
+
/** How a record tool's result says to read a field it cut. */
|
|
4
|
+
const cutRecordNote = "The fields in cut_fields were cut to fit the size limit, each with its full length; read the rest of one with get_record and its field, offset and max_chars arguments.";
|
|
5
|
+
const fewerNote = (count) => `Only ${count} records fit the size limit; pass next_page_token as page_token for the next ones. ${cutRecordNote}`;
|
|
6
|
+
/**
|
|
7
|
+
* A page of records as JSON within the size budget: its strings trimmed, then, when that does not fit, the page
|
|
8
|
+
* listed again with as many records as fit, so its next_page_token goes on from the last one returned.
|
|
9
|
+
*/
|
|
10
|
+
const budgetedPage = async (page, params, list) => {
|
|
11
|
+
const json = budgetedJson(page, cutRecordNote);
|
|
12
|
+
if (json) return json;
|
|
13
|
+
if (page.items.length <= 1) return forcedJson(page, cutRecordNote);
|
|
14
|
+
const fits = (count) => budgetedJson({
|
|
15
|
+
...page,
|
|
16
|
+
items: page.items.slice(0, count),
|
|
17
|
+
note: fewerNote(count)
|
|
18
|
+
}, fewerNote(count)) !== void 0;
|
|
19
|
+
const count = leadingFit(page.items.length - 1, fits);
|
|
20
|
+
const fewer = await list({
|
|
21
|
+
...params,
|
|
22
|
+
page_size: count
|
|
23
|
+
});
|
|
24
|
+
return forcedJson({
|
|
25
|
+
...fewer,
|
|
26
|
+
note: fewerNote(fewer.items.length)
|
|
27
|
+
}, fewerNote(fewer.items.length));
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { budgetedPage, cutRecordNote };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AssistantTool } from "./tools.js";
|
|
2
|
+
import { WriteRecordToolsInput } from "./write-tool-limits.js";
|
|
3
|
+
//#region src/assistant/comment-tools.d.ts
|
|
4
|
+
/** The resources whose records take comments (`.comments()`), the only ones the comment tools reach. */
|
|
5
|
+
export type CommentToolsInput = {
|
|
6
|
+
commented: string[];
|
|
7
|
+
};
|
|
8
|
+
/** Reads a page of a record's comments, newest first, under the caller's access rules. */
|
|
9
|
+
export declare const listCommentsTool: (input: CommentToolsInput) => AssistantTool;
|
|
10
|
+
/**
|
|
11
|
+
* Adds the caller's comment to a record once the user approves it, under the caller's access rules. Given `resources`,
|
|
12
|
+
* it comments only on those, and refuses any other before asking.
|
|
13
|
+
*/
|
|
14
|
+
export declare const addCommentTool: (input: CommentToolsInput & WriteRecordToolsInput) => AssistantTool;
|
|
15
|
+
/**
|
|
16
|
+
* `list_comments`, and `add_comment` when `writes` allows writing: on the resources with comments, and of those only
|
|
17
|
+
* the ones `writes` lists when it lists some. None when no resource takes comments.
|
|
18
|
+
*/
|
|
19
|
+
export declare const commentTools: ({ commented }: CommentToolsInput, writes: false | WriteRecordToolsInput) => AssistantTool[];
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { defineTool } from "./tools.js";
|
|
3
|
+
import { parseCommentInput } from "../comments/comment-input.js";
|
|
4
|
+
import { keyOf, keyParameter, refused, resourceOf, resourceParameter } from "./record-tool-args.js";
|
|
5
|
+
import { commentApproval } from "./write-prompts.js";
|
|
6
|
+
import { approvalTexts } from "./record-before.js";
|
|
7
|
+
import { notApproved, onlyNote, outside } from "./write-tool-limits.js";
|
|
8
|
+
//#region src/assistant/comment-tools.ts
|
|
9
|
+
const defaultPageSize = 20;
|
|
10
|
+
const maxPageSize = 100;
|
|
11
|
+
const resourceParameterOf = (resources) => ({
|
|
12
|
+
...resourceParameter,
|
|
13
|
+
description: `The resource's name: one of ${resources.join(", ")}, whose records take comments.`
|
|
14
|
+
});
|
|
15
|
+
const commentsOf = (context) => {
|
|
16
|
+
if (!context.comments) throw new Error("The comment tools need ToolContext.comments, which the MCP endpoint gives");
|
|
17
|
+
return context.comments;
|
|
18
|
+
};
|
|
19
|
+
const uncommented = ({ commented }, resource) => commented.includes(resource) ? void 0 : refused(notFound(`${resource} takes no comments`));
|
|
20
|
+
/** Reads a page of a record's comments, newest first, under the caller's access rules. */
|
|
21
|
+
const listCommentsTool = (input) => defineTool({
|
|
22
|
+
name: "list_comments",
|
|
23
|
+
description: "Lists the comments on one record, newest first, as JSON ({ items: [{ author, created_at, text }], next_page_token }). Pass next_page_token back as page_token for the next page; it is empty on the last.",
|
|
24
|
+
parameters: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
resource: resourceParameterOf(input.commented),
|
|
28
|
+
key: keyParameter,
|
|
29
|
+
page_size: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
minimum: 1,
|
|
32
|
+
maximum: maxPageSize,
|
|
33
|
+
description: `Comments per page, ${defaultPageSize} by default.`
|
|
34
|
+
},
|
|
35
|
+
page_token: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "The next_page_token of the previous page."
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
required: ["resource", "key"]
|
|
41
|
+
},
|
|
42
|
+
annotations: {
|
|
43
|
+
readOnlyHint: true,
|
|
44
|
+
openWorldHint: false
|
|
45
|
+
},
|
|
46
|
+
run: async (args, context) => {
|
|
47
|
+
const resource = resourceOf(args);
|
|
48
|
+
const key = keyOf(args.key);
|
|
49
|
+
if (!resource || key === void 0) return "Pass the resource and key as { \"resource\": \"<name>\", \"key\": \"<key>\" }.";
|
|
50
|
+
const refusal = uncommented(input, resource);
|
|
51
|
+
if (refusal) return refusal;
|
|
52
|
+
const params = {
|
|
53
|
+
page_size: typeof args.page_size === "number" ? Math.min(args.page_size, maxPageSize) : defaultPageSize,
|
|
54
|
+
...typeof args.page_token === "string" && args.page_token && { page_token: args.page_token }
|
|
55
|
+
};
|
|
56
|
+
return commentsOf(context).list(resource, key, params).then((page) => JSON.stringify(page), refused);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* Adds the caller's comment to a record once the user approves it, under the caller's access rules. Given `resources`,
|
|
61
|
+
* it comments only on those, and refuses any other before asking.
|
|
62
|
+
*/
|
|
63
|
+
const addCommentTool = (input) => defineTool({
|
|
64
|
+
name: "add_comment",
|
|
65
|
+
description: `Adds a comment by you to one record and returns it as JSON ({ author, created_at, text }). The user approves each comment in their client first; nothing is written without it.${onlyNote(input)}`,
|
|
66
|
+
parameters: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
resource: resourceParameterOf(input.resources ?? input.commented),
|
|
70
|
+
key: keyParameter,
|
|
71
|
+
text: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "The comment, plain text."
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
required: [
|
|
77
|
+
"resource",
|
|
78
|
+
"key",
|
|
79
|
+
"text"
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
annotations: {
|
|
83
|
+
readOnlyHint: false,
|
|
84
|
+
destructiveHint: false,
|
|
85
|
+
openWorldHint: false
|
|
86
|
+
},
|
|
87
|
+
run: async (args, context) => {
|
|
88
|
+
const resource = resourceOf(args);
|
|
89
|
+
const key = keyOf(args.key);
|
|
90
|
+
const text = typeof args.text === "string" ? args.text : void 0;
|
|
91
|
+
if (!resource || key === void 0 || text === void 0) return "Pass the resource, key and text as { \"resource\": \"<name>\", \"key\": \"<key>\", \"text\": \"<comment>\" }.";
|
|
92
|
+
const refusal = outside(input, resource) ?? uncommented(input, resource);
|
|
93
|
+
if (refusal) return refusal;
|
|
94
|
+
const invalid = await Promise.resolve().then(() => parseCommentInput({ body: text }, String(context.session.user.id))).then(() => void 0, refused);
|
|
95
|
+
if (invalid) return invalid;
|
|
96
|
+
const current = await context.records.get(resource, key).catch(refused);
|
|
97
|
+
if (typeof current === "string") return current;
|
|
98
|
+
const { text: texts, described } = await approvalTexts(context, resource);
|
|
99
|
+
const answer = await context.approve(commentApproval(texts, described, key, current.record, text));
|
|
100
|
+
if (answer !== "approved") return notApproved(answer);
|
|
101
|
+
return commentsOf(context).add(resource, key, text).then((comment) => JSON.stringify(comment), refused);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* `list_comments`, and `add_comment` when `writes` allows writing: on the resources with comments, and of those only
|
|
106
|
+
* the ones `writes` lists when it lists some. None when no resource takes comments.
|
|
107
|
+
*/
|
|
108
|
+
const commentTools = ({ commented }, writes) => {
|
|
109
|
+
if (commented.length === 0) return [];
|
|
110
|
+
const list = listCommentsTool({ commented });
|
|
111
|
+
if (writes === false) return [list];
|
|
112
|
+
const resources = writes.resources?.filter((name) => commented.includes(name));
|
|
113
|
+
if (resources?.length === 0) return [list];
|
|
114
|
+
return [list, addCommentTool({
|
|
115
|
+
commented,
|
|
116
|
+
...resources && { resources }
|
|
117
|
+
})];
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { addCommentTool, commentTools, listCommentsTool };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AssistantTool } from "./tools.js";
|
|
2
|
+
//#region src/assistant/count-records-tool.d.ts
|
|
3
|
+
/** Counts a resource's records, in all or per value of a field, through the REST API's `count=exact` and `:aggregate`. */
|
|
4
|
+
export declare const countRecordsTool: () => AssistantTool;
|
|
5
|
+
//#endregion
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { exactCountLimit } from "../list-service.js";
|
|
2
|
+
import { defineTool } from "./tools.js";
|
|
3
|
+
import { invalidShowDeleted, refused, resourceOf, resourceParameter, showDeletedOf, showDeletedParameter } from "./record-tool-args.js";
|
|
4
|
+
//#region src/assistant/count-records-tool.ts
|
|
5
|
+
const maxGroups = 1e3;
|
|
6
|
+
const invalid = (parameter, message) => JSON.stringify({
|
|
7
|
+
error: "invalid-parameter",
|
|
8
|
+
message: `Invalid parameter ${parameter}: ${message}`
|
|
9
|
+
});
|
|
10
|
+
const total = async (context, resource, filter, showDeleted) => {
|
|
11
|
+
const page = await context.records.list(resource, {
|
|
12
|
+
...filter !== void 0 && { filter },
|
|
13
|
+
page_size: 1,
|
|
14
|
+
count: "exact",
|
|
15
|
+
...showDeleted && { show_deleted: true }
|
|
16
|
+
});
|
|
17
|
+
if (page.total_size !== void 0) return { count: page.total_size };
|
|
18
|
+
return {
|
|
19
|
+
more_than: exactCountLimit,
|
|
20
|
+
estimate: page.total_size_estimate
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Counts a resource's records, in all or per value of a field, through the REST API's `count=exact` and `:aggregate`. */
|
|
24
|
+
const countRecordsTool = () => defineTool({
|
|
25
|
+
name: "count_records",
|
|
26
|
+
description: `Counts the records of a resource that a filter finds, without returning them, as JSON. Without group_by it answers { count }, or { more_than: ${exactCountLimit}, estimate } when more match. With group_by, a filterable field, it answers { group_by, groups: [{ value, count, label? }] }: the records per value, largest first, adding up to what the filter finds; a date or timestamp group_by counts per day over the last 30 days instead. filter uses the AIP-160 syntax of list_records.`,
|
|
27
|
+
parameters: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
resource: resourceParameter,
|
|
31
|
+
filter: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "An AIP-160 filter on filterable fields, such as status = \"open\" AND amount > 10."
|
|
34
|
+
},
|
|
35
|
+
group_by: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "A filterable field to count the records of per value, such as status."
|
|
38
|
+
},
|
|
39
|
+
limit: {
|
|
40
|
+
type: "integer",
|
|
41
|
+
minimum: 1,
|
|
42
|
+
maximum: maxGroups,
|
|
43
|
+
description: "With group_by, the most groups to return, 100 by default."
|
|
44
|
+
},
|
|
45
|
+
label: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "With a relation group_by, a field of the linked resource to name each value by, such as name."
|
|
48
|
+
},
|
|
49
|
+
show_deleted: {
|
|
50
|
+
...showDeletedParameter,
|
|
51
|
+
description: "true also counts soft-deleted records; only without group_by, on a resource list_resources marks soft_delete."
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
required: ["resource"]
|
|
55
|
+
},
|
|
56
|
+
annotations: {
|
|
57
|
+
readOnlyHint: true,
|
|
58
|
+
openWorldHint: false
|
|
59
|
+
},
|
|
60
|
+
run: async (args, context) => {
|
|
61
|
+
const resource = resourceOf(args);
|
|
62
|
+
if (!resource) return "Pass the resource as { \"resource\": \"<name>\" }.";
|
|
63
|
+
const showDeleted = showDeletedOf(args);
|
|
64
|
+
if (showDeleted === void 0) return invalidShowDeleted;
|
|
65
|
+
const filter = typeof args.filter === "string" ? args.filter : void 0;
|
|
66
|
+
const groupBy = typeof args.group_by === "string" && args.group_by ? args.group_by : void 0;
|
|
67
|
+
if (groupBy === void 0) {
|
|
68
|
+
if (args.limit !== void 0 || args.label !== void 0) return invalid(args.limit !== void 0 ? "limit" : "label", "Only with group_by");
|
|
69
|
+
return total(context, resource, filter, showDeleted).then((counted) => JSON.stringify(counted), refused);
|
|
70
|
+
}
|
|
71
|
+
if (showDeleted) return invalid("show_deleted", "Not with group_by");
|
|
72
|
+
if (args.limit !== void 0 && !Number.isInteger(args.limit)) return invalid("limit", "Pass a whole number");
|
|
73
|
+
if (args.label !== void 0 && typeof args.label !== "string") return invalid("label", "Pass a field name");
|
|
74
|
+
const params = {
|
|
75
|
+
group_by: groupBy,
|
|
76
|
+
...filter !== void 0 && { filter },
|
|
77
|
+
...typeof args.limit === "number" && { limit: args.limit },
|
|
78
|
+
...typeof args.label === "string" && { label: args.label }
|
|
79
|
+
};
|
|
80
|
+
return context.records.aggregate(resource, params).then((groups) => JSON.stringify(groups), refused);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
//#endregion
|
|
84
|
+
export { countRecordsTool };
|