@segmentstream-cloud/ontology 0.17.0 → 0.18.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 +60 -0
- package/dist/analyze.js +5 -4
- package/dist/authoring-records.js +8 -5
- package/dist/blob.d.ts +8 -1
- package/dist/blob.js +34 -0
- package/dist/property.d.ts +4 -3
- package/dist/property.js +6 -1
- package/dist/query.d.ts +18 -10
- package/dist/query.js +58 -18
- package/dist/runtime/reads.js +2 -2
- package/dist/runtime.d.ts +2 -1
- package/dist/service/mcp/tools.js +7 -2
- package/dist/service/operations.d.ts +1 -1
- package/dist/service/operations.js +3 -2
- package/dist/service/schemas.js +1 -1
- package/dist/storage/attachments.js +2 -2
- package/dist/storage/sessions.js +7 -5
- package/dist/wire.d.ts +6 -1
- package/dist/wire.js +18 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,10 @@ take the `notEquals`, `notIn`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith
|
|
|
40
40
|
SDK 0.14.0 lets the app's own agent call the service as a paired member (see
|
|
41
41
|
[Agents](#agents)). Its migration adds the agent's runtime tables.
|
|
42
42
|
|
|
43
|
+
SDK 0.18.0 lets blob properties store any file. `accept` is optional and can restrict a
|
|
44
|
+
property to exact MIME types or ranges. Existing explicit restrictions keep their meaning.
|
|
45
|
+
See [File storage](#file-storage).
|
|
46
|
+
|
|
43
47
|
SDK 0.12.0 adds warehouse replication enabled by default for all stored business entities,
|
|
44
48
|
fields and business relationships. Existing history captures immutable snapshots and the
|
|
45
49
|
service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace
|
|
@@ -73,6 +77,62 @@ SDK 0.13.0 also serves aggregates. Upgrade notes:
|
|
|
73
77
|
See [ontology aggregates](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates.md)
|
|
74
78
|
and [ontology analytics](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates-spec.md).
|
|
75
79
|
|
|
80
|
+
## File storage
|
|
81
|
+
|
|
82
|
+
Use `p.blob({ description: "Original source file." })` to retain a file's original bytes.
|
|
83
|
+
Without `accept`, a blob property accepts every concrete MIME type, including custom formats
|
|
84
|
+
and `application/octet-stream`. `p.blob()` also defaults to all files, though ontology checks
|
|
85
|
+
require a description. The manifest publishes this policy as `accept: ["*/*"]`.
|
|
86
|
+
|
|
87
|
+
Restrict a property when its business rules require a format:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
content: p.blob({
|
|
91
|
+
description: "Original POS report or bank statement.",
|
|
92
|
+
accept: [
|
|
93
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
94
|
+
"application/vnd.ms-excel",
|
|
95
|
+
"text/csv",
|
|
96
|
+
"text/tab-separated-values",
|
|
97
|
+
],
|
|
98
|
+
maxBytes: 10 * 1024 * 1024,
|
|
99
|
+
}),
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`accept` takes MIME types, not extensions. Matching ignores case; `image/*` accepts image
|
|
103
|
+
types and `*/*` accepts all types. Empty lists, duplicate entries, invalid MIME types and
|
|
104
|
+
parameters such as `; charset=utf-8` are rejected during ontology checks.
|
|
105
|
+
`BLOB_CONTENT_TYPES` lists common types as a convenience; it is not a global allow-list.
|
|
106
|
+
|
|
107
|
+
| Source format | MIME type |
|
|
108
|
+
| --------------------- | ------------------------------------------------------------------------- |
|
|
109
|
+
| XLSX | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
|
|
110
|
+
| XLS | `application/vnd.ms-excel` |
|
|
111
|
+
| CSV | `text/csv` |
|
|
112
|
+
| TSV | `text/tab-separated-values` |
|
|
113
|
+
| JSON | `application/json` |
|
|
114
|
+
| TXT | `text/plain` |
|
|
115
|
+
| DOCX | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
|
|
116
|
+
| ZIP | `application/zip` |
|
|
117
|
+
| Unknown binary format | `application/octet-stream` |
|
|
118
|
+
|
|
119
|
+
Uploads still require a valid concrete MIME type, a nonempty file within the property's
|
|
120
|
+
`maxBytes` and the 25 MiB service limit, available workspace quota and action permissions.
|
|
121
|
+
Completion verifies the stored MIME metadata, exact byte count and upload-session binding.
|
|
122
|
+
PDF, JPEG and PNG retain their prefix-signature checks; plain text retains its UTF-8 prefix
|
|
123
|
+
check. Other formats are stored as opaque bytes, without parsing, conversion, archive
|
|
124
|
+
extraction or malware scanning. A MIME declaration does not prove what a file contains.
|
|
125
|
+
Downloads use attachment disposition; only JPEG and PNG may request inline display.
|
|
126
|
+
Agent attachment promotion honors the same `accept` and `maxBytes` restrictions.
|
|
127
|
+
|
|
128
|
+
Upgrade the ontology SDK to 0.18.0 or later, update any existing `accept` list that should
|
|
129
|
+
allow more types, then check, migrate and redeploy the ontology. The app SDK storage client
|
|
130
|
+
0.13.1 or later uses `application/octet-stream` when the browser supplies no MIME type.
|
|
131
|
+
REST callers should supply that type themselves for unknown formats.
|
|
132
|
+
|
|
133
|
+
See [blob storage](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-blob-storage-implementation.md)
|
|
134
|
+
for upload tickets, transfer grants and retained revisions.
|
|
135
|
+
|
|
76
136
|
## Define the ontology
|
|
77
137
|
|
|
78
138
|
```ts
|
package/dist/analyze.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkWarehouseSource } from "./warehouse-backed.js";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
3
|
import { BLOB_UPLOAD_ANNOTATION } from "./blob.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isBlobAccept, MAX_BLOB_BYTES } from "./blob.js";
|
|
5
5
|
import { isAction } from "./action.js";
|
|
6
6
|
import { CONNECTION_KEY, isConnection } from "./connection.js";
|
|
7
7
|
import { isEntity } from "./entity.js";
|
|
@@ -411,12 +411,13 @@ function checkProperty(path, schema, definition, report) {
|
|
|
411
411
|
report("invalid-property", path, "Blob properties cannot be unique or have a default.");
|
|
412
412
|
}
|
|
413
413
|
if (!definition.accept?.length ||
|
|
414
|
-
definition.accept.some((type) => !
|
|
415
|
-
new Set(definition.accept)
|
|
414
|
+
definition.accept.some((type) => !isBlobAccept(type)) ||
|
|
415
|
+
new Set(definition.accept.map((type) => type.toLowerCase())).size !==
|
|
416
|
+
definition.accept.length ||
|
|
416
417
|
!Number.isInteger(definition.maxBytes) ||
|
|
417
418
|
definition.maxBytes < 1 ||
|
|
418
419
|
definition.maxBytes > MAX_BLOB_BYTES) {
|
|
419
|
-
report("invalid-constraint", path, "Use
|
|
420
|
+
report("invalid-constraint", path, "Use MIME types or ranges and a limit from 1 byte to 25 MiB.");
|
|
420
421
|
}
|
|
421
422
|
}
|
|
422
423
|
if (definition.type === "select") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isEntity } from "./entity.js";
|
|
2
2
|
import { toCamelCase } from "./names.js";
|
|
3
3
|
import { propertyDefinition } from "./property.js";
|
|
4
|
+
import { isIncludeList } from "./query.js";
|
|
4
5
|
import { isRecord, nameToWire } from "./authoring-names.js";
|
|
5
6
|
function targetDefinition(target) {
|
|
6
7
|
const resolved = typeof target === "function" ? target() : target;
|
|
@@ -113,13 +114,15 @@ export function findToWire(options, definition) {
|
|
|
113
114
|
: {
|
|
114
115
|
orderBy: options.orderBy.map((order) => ({ ...order, field: nameToWire(order.field) })),
|
|
115
116
|
}),
|
|
116
|
-
...(options.include === undefined
|
|
117
|
-
? {}
|
|
118
|
-
: {
|
|
119
|
-
include: Object.fromEntries(Object.entries(options.include).map(([name, value]) => [nameToWire(name), value])),
|
|
120
|
-
}),
|
|
117
|
+
...(options.include === undefined ? {} : { include: includeToWire(options.include) }),
|
|
121
118
|
};
|
|
122
119
|
}
|
|
120
|
+
/** The relationships a find includes, by wire name, in the form the handler gave them. */
|
|
121
|
+
function includeToWire(include) {
|
|
122
|
+
return isIncludeList(include)
|
|
123
|
+
? include.map(nameToWire)
|
|
124
|
+
: Object.fromEntries(Object.entries(include).map(([name, value]) => [nameToWire(name), value]));
|
|
125
|
+
}
|
|
123
126
|
/** Current action edits use the same property names and values as authored entity records. */
|
|
124
127
|
export function editsFromWire(edits, entities) {
|
|
125
128
|
return edits.map((edit) => {
|
package/dist/blob.d.ts
CHANGED
|
@@ -20,7 +20,14 @@ export declare const BlobUpload: Schema.Struct<{
|
|
|
20
20
|
export type BlobUpload = typeof BlobUpload.Type;
|
|
21
21
|
export declare const BLOB_UPLOAD_ANNOTATION = "x-ontology-upload";
|
|
22
22
|
export declare const MAX_BLOB_BYTES: number;
|
|
23
|
-
|
|
23
|
+
/** Common content types for file properties; any valid MIME type may be declared. */
|
|
24
|
+
export declare const BLOB_CONTENT_TYPES: readonly ["application/pdf", "image/jpeg", "image/png", "text/plain", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel", "text/csv", "text/tab-separated-values", "application/json", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/zip", "application/octet-stream"];
|
|
25
|
+
/** A concrete MIME type, without wildcards, parameters or header control characters. */
|
|
26
|
+
export declare function isBlobContentType(contentType: string): boolean;
|
|
27
|
+
/** A property's MIME restriction: a concrete type, a type range, or all files. */
|
|
28
|
+
export declare function isBlobAccept(accept: string): boolean;
|
|
29
|
+
/** Omitted restrictions accept every concrete MIME type; an empty list accepts nothing. */
|
|
30
|
+
export declare function acceptsBlobContentType(accept: ReadonlyArray<string> | undefined, contentType: string): boolean;
|
|
24
31
|
type SchemaValue<S> = S extends {
|
|
25
32
|
readonly Type: infer T;
|
|
26
33
|
} ? T : S extends Schema.optionalKey<infer Inner> ? Inner["Type"] : never;
|
package/dist/blob.js
CHANGED
|
@@ -28,12 +28,46 @@ function uploadCodec(entity, property) {
|
|
|
28
28
|
export const BlobUpload = Schema.Struct({ uploadId: Schema.String.check(Schema.isUUID()) });
|
|
29
29
|
export const BLOB_UPLOAD_ANNOTATION = "x-ontology-upload";
|
|
30
30
|
export const MAX_BLOB_BYTES = 25 * 1024 * 1024;
|
|
31
|
+
/** Common content types for file properties; any valid MIME type may be declared. */
|
|
31
32
|
export const BLOB_CONTENT_TYPES = [
|
|
32
33
|
"application/pdf",
|
|
33
34
|
"image/jpeg",
|
|
34
35
|
"image/png",
|
|
35
36
|
"text/plain",
|
|
37
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
38
|
+
"application/vnd.ms-excel",
|
|
39
|
+
"text/csv",
|
|
40
|
+
"text/tab-separated-values",
|
|
41
|
+
"application/json",
|
|
42
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
43
|
+
"application/zip",
|
|
44
|
+
"application/octet-stream",
|
|
36
45
|
];
|
|
46
|
+
const CONTENT_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
|
|
47
|
+
const CONTENT_TYPE_RANGE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/\*$/i;
|
|
48
|
+
/** A concrete MIME type, without wildcards, parameters or header control characters. */
|
|
49
|
+
export function isBlobContentType(contentType) {
|
|
50
|
+
return contentType === contentType.trim() && CONTENT_TYPE.test(contentType);
|
|
51
|
+
}
|
|
52
|
+
/** A property's MIME restriction: a concrete type, a type range, or all files. */
|
|
53
|
+
export function isBlobAccept(accept) {
|
|
54
|
+
return (accept === accept.trim() &&
|
|
55
|
+
(accept === "*/*" || CONTENT_TYPE_RANGE.test(accept) || isBlobContentType(accept)));
|
|
56
|
+
}
|
|
57
|
+
/** Omitted restrictions accept every concrete MIME type; an empty list accepts nothing. */
|
|
58
|
+
export function acceptsBlobContentType(accept, contentType) {
|
|
59
|
+
if (!isBlobContentType(contentType)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const normalized = contentType.toLowerCase();
|
|
63
|
+
return (accept === undefined ||
|
|
64
|
+
accept.some((candidate) => {
|
|
65
|
+
const pattern = candidate.toLowerCase();
|
|
66
|
+
return (pattern === "*/*" ||
|
|
67
|
+
pattern === normalized ||
|
|
68
|
+
(CONTENT_TYPE_RANGE.test(pattern) && normalized.startsWith(pattern.slice(0, -1))));
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
37
71
|
/** Internal wire codec for session tickets and generated action inputs. */
|
|
38
72
|
export function uploadInput(entity, property) {
|
|
39
73
|
return uploadCodec(entity, property);
|
package/dist/property.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
-
import { BlobValue
|
|
2
|
+
import { BlobValue } from "./blob.js";
|
|
3
3
|
/** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
|
|
4
4
|
export declare const PROPERTY_TYPES: readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"];
|
|
5
5
|
export type PropertyType = (typeof PROPERTY_TYPES)[number];
|
|
@@ -131,10 +131,11 @@ declare function ref<const O extends ReferenceOptions>(target: string | {
|
|
|
131
131
|
}, options?: O): ReferenceOf<O>;
|
|
132
132
|
/** Property constructors. */
|
|
133
133
|
export interface BlobOptions extends Omit<PropertyOptions<BlobValue>, "default" | "unique"> {
|
|
134
|
-
|
|
134
|
+
/** MIME types or ranges such as image/*; omitted means any file. */
|
|
135
|
+
readonly accept?: ReadonlyArray<string>;
|
|
135
136
|
readonly maxBytes?: number;
|
|
136
137
|
}
|
|
137
|
-
declare function blob<const O extends BlobOptions>(options
|
|
138
|
+
declare function blob<const O extends BlobOptions>(options?: O): PropertyOf<BlobValue, O>;
|
|
138
139
|
export declare const p: {
|
|
139
140
|
readonly text: typeof text;
|
|
140
141
|
readonly email: typeof email;
|
package/dist/property.js
CHANGED
|
@@ -181,10 +181,15 @@ function ref(target, options) {
|
|
|
181
181
|
return (settings.optional === true ? Schema.optionalKey(schema) : schema);
|
|
182
182
|
}
|
|
183
183
|
function blob(options) {
|
|
184
|
+
const settings = options ?? {};
|
|
184
185
|
return construct({
|
|
185
186
|
type: "blob",
|
|
186
187
|
base: BlobValue,
|
|
187
|
-
options: {
|
|
188
|
+
options: {
|
|
189
|
+
...settings,
|
|
190
|
+
accept: settings.accept ?? ["*/*"],
|
|
191
|
+
maxBytes: settings.maxBytes ?? MAX_BLOB_BYTES,
|
|
192
|
+
},
|
|
188
193
|
});
|
|
189
194
|
}
|
|
190
195
|
export const p = {
|
package/dist/query.d.ts
CHANGED
|
@@ -16,14 +16,15 @@ export type StorageType = PropertyType | "uuid";
|
|
|
16
16
|
* "or": [{ "fitScore": { "gte": 80 } }, { "source": "pilot_request" }]
|
|
17
17
|
* },
|
|
18
18
|
* "orderBy": [{ "field": "created_at", "direction": "desc" }],
|
|
19
|
-
* "include":
|
|
19
|
+
* "include": ["owner"],
|
|
20
20
|
* "limit": 50
|
|
21
21
|
* }
|
|
22
22
|
* ```
|
|
23
23
|
*
|
|
24
24
|
* {@link Query} checks the structure for any entity; {@link entityQuerySchema} builds the precise
|
|
25
25
|
* schema of one entity from the manifest, with its fields, their operators and its relationships.
|
|
26
|
-
* The runtime validates every query with the precise schema.
|
|
26
|
+
* The runtime validates every query with the precise schema. `include` also takes the object form
|
|
27
|
+
* `{ "owner": true }`, which a list of names decodes into.
|
|
27
28
|
*/
|
|
28
29
|
export declare const DEFAULT_QUERY_LIMIT = 50;
|
|
29
30
|
export declare const MAX_QUERY_LIMIT = 200;
|
|
@@ -78,6 +79,14 @@ export declare const OrderBy: Schema.Struct<{
|
|
|
78
79
|
readonly direction: Schema.optionalKey<Schema.Literals<readonly ["asc", "desc"]>>;
|
|
79
80
|
}>;
|
|
80
81
|
export type OrderBy = typeof OrderBy.Type;
|
|
82
|
+
/** The relationships to include, once decoded: `{ owner: true, tags: true }`. */
|
|
83
|
+
export type Included = {
|
|
84
|
+
readonly [relationship: string]: true;
|
|
85
|
+
};
|
|
86
|
+
/** Whether an `include` is the list form, `["owner", "tags"]`. */
|
|
87
|
+
export declare function isIncludeList<L extends string>(include: ReadonlyArray<L> | object): include is ReadonlyArray<L>;
|
|
88
|
+
/** The relationship names an `include` lists, in either form. */
|
|
89
|
+
export declare function includedRelationships(include: Query["include"]): ReadonlyArray<string>;
|
|
81
90
|
/** A query on any entity. The runtime checks the fields against the entity. */
|
|
82
91
|
export declare const Query: Schema.Struct<{
|
|
83
92
|
readonly where: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
@@ -85,17 +94,18 @@ export declare const Query: Schema.Struct<{
|
|
|
85
94
|
readonly field: Schema.String;
|
|
86
95
|
readonly direction: Schema.optionalKey<Schema.Literals<readonly ["asc", "desc"]>>;
|
|
87
96
|
}>>>;
|
|
88
|
-
readonly include: Schema.optionalKey<Schema
|
|
97
|
+
readonly include: Schema.optionalKey<Schema.Codec<Included, readonly string[] | Included, never, never>>;
|
|
89
98
|
readonly after: Schema.optionalKey<Schema.String>;
|
|
90
99
|
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
91
100
|
}>;
|
|
92
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* A query. `where` follows {@link Where}; `include` adds related records by relationship name, as a
|
|
103
|
+
* list, `["owner", "tags"]`, or as an object, `{ owner: true, tags: true }`.
|
|
104
|
+
*/
|
|
93
105
|
export interface Query {
|
|
94
106
|
readonly where?: Where;
|
|
95
107
|
readonly orderBy?: ReadonlyArray<OrderBy>;
|
|
96
|
-
readonly include?:
|
|
97
|
-
readonly [relationship: string]: true;
|
|
98
|
-
};
|
|
108
|
+
readonly include?: ReadonlyArray<string> | Included;
|
|
99
109
|
readonly after?: string;
|
|
100
110
|
readonly limit?: number;
|
|
101
111
|
}
|
|
@@ -160,9 +170,7 @@ export declare function isOrderable(field: QueryField): boolean;
|
|
|
160
170
|
export interface EntityQuerySchemas {
|
|
161
171
|
readonly where: Schema.Codec<Where>;
|
|
162
172
|
readonly orderBy: Schema.Codec<ReadonlyArray<OrderBy>>;
|
|
163
|
-
readonly include: Schema.Codec<
|
|
164
|
-
readonly [relationship: string]: true;
|
|
165
|
-
}>;
|
|
173
|
+
readonly include: Schema.Codec<Included, ReadonlyArray<string> | Included>;
|
|
166
174
|
readonly limit: Schema.Codec<number>;
|
|
167
175
|
readonly after: Schema.Codec<string>;
|
|
168
176
|
readonly query: Schema.Codec<Query>;
|
package/dist/query.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
import { wireStruct } from "./wire.js";
|
|
1
|
+
import { Schema, SchemaTransformation } from "effect";
|
|
2
|
+
import { expectedOneOf, wireStruct } from "./wire.js";
|
|
3
3
|
import { isValueMatched } from "./manifest.js";
|
|
4
4
|
import { BlobValue } from "./blob.js";
|
|
5
5
|
/**
|
|
@@ -14,14 +14,15 @@ import { BlobValue } from "./blob.js";
|
|
|
14
14
|
* "or": [{ "fitScore": { "gte": 80 } }, { "source": "pilot_request" }]
|
|
15
15
|
* },
|
|
16
16
|
* "orderBy": [{ "field": "created_at", "direction": "desc" }],
|
|
17
|
-
* "include":
|
|
17
|
+
* "include": ["owner"],
|
|
18
18
|
* "limit": 50
|
|
19
19
|
* }
|
|
20
20
|
* ```
|
|
21
21
|
*
|
|
22
22
|
* {@link Query} checks the structure for any entity; {@link entityQuerySchema} builds the precise
|
|
23
23
|
* schema of one entity from the manifest, with its fields, their operators and its relationships.
|
|
24
|
-
* The runtime validates every query with the precise schema.
|
|
24
|
+
* The runtime validates every query with the precise schema. `include` also takes the object form
|
|
25
|
+
* `{ "owner": true }`, which a list of names decodes into.
|
|
25
26
|
*/
|
|
26
27
|
export const DEFAULT_QUERY_LIMIT = 50;
|
|
27
28
|
export const MAX_QUERY_LIMIT = 200;
|
|
@@ -56,11 +57,32 @@ const Limit = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_QUERY
|
|
|
56
57
|
const After = Schema.String.annotate({
|
|
57
58
|
description: "The nextCursor of the previous page. It is only valid for the same where and orderBy.",
|
|
58
59
|
});
|
|
60
|
+
/** Whether an `include` is the list form, `["owner", "tags"]`. */
|
|
61
|
+
export function isIncludeList(include) {
|
|
62
|
+
return Array.isArray(include);
|
|
63
|
+
}
|
|
64
|
+
/** The relationship names an `include` lists, in either form. */
|
|
65
|
+
export function includedRelationships(include) {
|
|
66
|
+
if (include === undefined) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return isIncludeList(include) ? [...new Set(include)] : Object.keys(include);
|
|
70
|
+
}
|
|
71
|
+
/** `["owner", "tags"]` decoded as `{ owner: true, tags: true }`; duplicate names are harmless. */
|
|
72
|
+
function includeList(names, included) {
|
|
73
|
+
return Schema.Array(names).pipe(Schema.decodeTo(included, SchemaTransformation.transform({
|
|
74
|
+
decode: (list) => Object.fromEntries(list.map((name) => [name, true])),
|
|
75
|
+
encode: (relationships) => Object.keys(relationships),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
const AnyIncluded = Schema.Record(Schema.String, Schema.Literal(true));
|
|
59
79
|
/** A query on any entity. The runtime checks the fields against the entity. */
|
|
60
80
|
export const Query = Schema.Struct({
|
|
61
81
|
where: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
|
62
82
|
orderBy: Schema.optionalKey(Schema.Array(OrderBy)),
|
|
63
|
-
include: Schema.optionalKey(
|
|
83
|
+
include: Schema.optionalKey(
|
|
84
|
+
// The list member decodes into the object; the union's static type cannot say so.
|
|
85
|
+
Schema.Union([AnyIncluded, includeList(Schema.String, AnyIncluded)])),
|
|
64
86
|
after: Schema.optionalKey(After),
|
|
65
87
|
limit: Schema.optionalKey(Limit),
|
|
66
88
|
}).annotate({ identifier: "Query" });
|
|
@@ -260,7 +282,7 @@ function fieldFilterSchema(field, identifier, wire) {
|
|
|
260
282
|
operators.between = Schema.optionalKey(Schema.Tuple([value, value]));
|
|
261
283
|
}
|
|
262
284
|
const filter = wire
|
|
263
|
-
? wireStruct(operators, { isNull: "is_null", startsWith: "starts_with" })
|
|
285
|
+
? wireStruct(operators, { isNull: "is_null", startsWith: "starts_with" }, "Unknown operator")
|
|
264
286
|
: closedStruct(operators, "Unknown operator");
|
|
265
287
|
const note = wire
|
|
266
288
|
? FILTER_DESCRIPTIONS[identifier]?.replaceAll("startsWith", "starts_with")
|
|
@@ -330,7 +352,7 @@ function manyFilterSchema(link, target) {
|
|
|
330
352
|
some: Schema.optionalKey(target),
|
|
331
353
|
none: Schema.optionalKey(target),
|
|
332
354
|
every: Schema.optionalKey(target),
|
|
333
|
-
}, "Unknown quantifier
|
|
355
|
+
}, "Unknown quantifier").annotate({
|
|
334
356
|
description: `Whether some, none or every one of the related ${link.target} records match.`,
|
|
335
357
|
});
|
|
336
358
|
}
|
|
@@ -351,18 +373,13 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
|
|
|
351
373
|
const entity = requireEntity(manifest, entityName);
|
|
352
374
|
const pascal = pascalCase(entityName);
|
|
353
375
|
const fields = queryFields(entity).filter(isOrderable);
|
|
354
|
-
const
|
|
355
|
-
for (const link of queryLinks(manifest, entityName)) {
|
|
356
|
-
include[link.name] = Schema.optionalKey(Schema.Literal(true));
|
|
357
|
-
}
|
|
376
|
+
const relationships = queryLinks(manifest, entityName).map((link) => link.name);
|
|
358
377
|
const where = whereSchema(manifest, entity, true, new Map(), wire);
|
|
359
378
|
const orderBy = Schema.Array(closedStruct({
|
|
360
379
|
field: Schema.Literals(fields.map((field) => field.name)),
|
|
361
380
|
direction: Schema.optionalKey(SortDirection),
|
|
362
|
-
}, "Unknown key
|
|
363
|
-
const included =
|
|
364
|
-
description: "Related records to add to each record, by relationship name.",
|
|
365
|
-
});
|
|
381
|
+
}, "Unknown key")).annotate({ description: "Sort keys in order; the id breaks ties. Ascending by default." });
|
|
382
|
+
const included = includeSchema(relationships);
|
|
366
383
|
const queryMembers = {
|
|
367
384
|
where: Schema.optionalKey(where),
|
|
368
385
|
orderBy: Schema.optionalKey(orderBy),
|
|
@@ -372,7 +389,7 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
|
|
|
372
389
|
};
|
|
373
390
|
const query = (wire
|
|
374
391
|
? wireStruct(queryMembers, { orderBy: "order_by" })
|
|
375
|
-
: closedStruct(queryMembers, "Unknown key
|
|
392
|
+
: closedStruct(queryMembers, "Unknown key")).annotate({ identifier: `${pascal}Query`, description: `A query on ${entity.plural}.` });
|
|
376
393
|
// The structs are assembled from the manifest, so their static types are too wide to name; they
|
|
377
394
|
// accept exactly the Query shape and decode it unchanged.
|
|
378
395
|
const schemas = {
|
|
@@ -388,6 +405,28 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
|
|
|
388
405
|
cache.set(manifest, byEntity);
|
|
389
406
|
return schemas;
|
|
390
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* The relationships a query includes: a list of names, `["owner", "tags"]`, or the object form
|
|
410
|
+
* `{ owner: true, tags: true }` that the list decodes into. Both reject an unknown relationship and
|
|
411
|
+
* name the ones there are.
|
|
412
|
+
*/
|
|
413
|
+
function includeSchema(relationships) {
|
|
414
|
+
const fields = {};
|
|
415
|
+
for (const name of relationships) {
|
|
416
|
+
fields[name] = Schema.optionalKey(Schema.Literal(true));
|
|
417
|
+
}
|
|
418
|
+
const included = closedStruct(fields, "Unknown relationship");
|
|
419
|
+
const name = Schema.String.check(Schema.makeFilter((candidate) => relationships.includes(candidate) ||
|
|
420
|
+
expectedOneOf(`Unknown relationship "${candidate}"`, relationships), { toJsonSchema: () => (relationships.length > 0 ? { enum: relationships } : { not: {} }) }));
|
|
421
|
+
const example = relationships[0];
|
|
422
|
+
const forms = example === undefined
|
|
423
|
+
? "a list of relationship names; there are none"
|
|
424
|
+
: `a list of relationship names, as in ["${example}"], or an object, as in {"${example}": true}`;
|
|
425
|
+
return Schema.Union([included, includeList(name, included)]).annotate({
|
|
426
|
+
description: `Related records to add to each record: ${forms}.`,
|
|
427
|
+
message: `Expected ${forms}`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
391
430
|
/** The query schema of one entity; see {@link entityQuerySchemas}. */
|
|
392
431
|
export function entityQuerySchema(manifest, entityName) {
|
|
393
432
|
return entityQuerySchemas(manifest, entityName).query;
|
|
@@ -401,10 +440,11 @@ function escapeRegExp(value) {
|
|
|
401
440
|
* same with a `patternProperties` entry that matches every undeclared key.
|
|
402
441
|
*/
|
|
403
442
|
function closedStruct(fields, unknownKey) {
|
|
404
|
-
const
|
|
443
|
+
const keys = Object.keys(fields);
|
|
444
|
+
const declared = keys.map(escapeRegExp).join("|");
|
|
405
445
|
const undeclared = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${declared})$)`)));
|
|
406
446
|
return Schema.StructWithRest(Schema.Struct(fields), [
|
|
407
|
-
Schema.Record(undeclared, Schema.Never.annotate({ message: unknownKey })),
|
|
447
|
+
Schema.Record(undeclared, Schema.Never.annotate({ message: expectedOneOf(unknownKey, keys) })),
|
|
408
448
|
]);
|
|
409
449
|
}
|
|
410
450
|
function recordValueSchema(field) {
|
package/dist/runtime/reads.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
import { NotFound } from "../errors.js";
|
|
3
|
-
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, isInstant, } from "../query.js";
|
|
3
|
+
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, includedRelationships, isInstant, } from "../query.js";
|
|
4
4
|
import { quoteIdentifier } from "../migrate/storage.js";
|
|
5
5
|
import { afterCondition, decodeCursor, encodeCursor, orderByClause, sortKeys } from "./cursor.js";
|
|
6
6
|
import { InvalidQuery } from "./errors.js";
|
|
@@ -120,7 +120,7 @@ export function runQuery(context, entity, query, options = {}) {
|
|
|
120
120
|
export const MAX_INCLUDED = MAX_QUERY_LIMIT;
|
|
121
121
|
function includeRelated(context, entity, records, include) {
|
|
122
122
|
return Effect.gen(function* () {
|
|
123
|
-
const names =
|
|
123
|
+
const names = includedRelationships(include);
|
|
124
124
|
let items = [...records];
|
|
125
125
|
if (records.length === 0) {
|
|
126
126
|
return items;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -60,7 +60,8 @@ export interface FindOptions<R = StoredRecord, L extends string = never> {
|
|
|
60
60
|
readonly field: keyof R & string;
|
|
61
61
|
readonly direction?: "asc" | "desc";
|
|
62
62
|
}>;
|
|
63
|
-
|
|
63
|
+
/** Relationships to add to each record: `["owner", "tags"]`, or `{ owner: true, tags: true }`. */
|
|
64
|
+
readonly include?: ReadonlyArray<L> | {
|
|
64
65
|
readonly [K in L]?: true;
|
|
65
66
|
};
|
|
66
67
|
readonly after?: string;
|
|
@@ -88,7 +88,7 @@ function relatedHint(planned, traversal) {
|
|
|
88
88
|
const find = mcpToolName(traversal.target, "find");
|
|
89
89
|
switch (traversal.kind) {
|
|
90
90
|
case "one":
|
|
91
|
-
return (`${traversal.name}: include
|
|
91
|
+
return (`${traversal.name}: include ["${traversal.name}"], or ` +
|
|
92
92
|
`${mcpToolName(traversal.target, "get")} with ${traversal.name}_id.`);
|
|
93
93
|
case "inverseOfOne":
|
|
94
94
|
return `${traversal.name}: ${find} with where {"${traversal.opposite}": {"id": {"eq": "${id}"}}}.`;
|
|
@@ -122,12 +122,17 @@ function readTools(planned, schemas, built) {
|
|
|
122
122
|
...planned.traversals.map((traversal) => relatedHint(planned, traversal)),
|
|
123
123
|
...matchedHints(planned),
|
|
124
124
|
].map((hint) => `- ${hint}`);
|
|
125
|
+
const includable = planned.traversals.slice(0, 2).map((traversal) => `"${traversal.name}"`);
|
|
126
|
+
const including = includable.length === 0
|
|
127
|
+
? ""
|
|
128
|
+
: ` Pass include as a list of relationship names, as in [${includable.join(", ")}].`;
|
|
125
129
|
return [
|
|
126
130
|
{
|
|
127
131
|
name: mcpToolName(planned.name, "find"),
|
|
128
132
|
title: `Find ${definition.plural.toLowerCase()}`,
|
|
129
133
|
description: `Filter, sort and page through ${definition.plural.toLowerCase()}, at most 200 at a ` +
|
|
130
|
-
`time; pass next_cursor as after to read the next page
|
|
134
|
+
`time; pass next_cursor as after to read the next page.${including} ` +
|
|
135
|
+
`${definition.description}\n\n` +
|
|
131
136
|
`${requires([read])} Including a relationship, or filtering through one, requires ` +
|
|
132
137
|
"reading its entity too." +
|
|
133
138
|
(related.length > 0 ? `\n\nRelated records:\n${related.join("\n")}` : ""),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
3
|
import { PermissionDenied } from "../errors.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type Query, type QueryResult } from "../query.js";
|
|
5
5
|
import { CurrentPrincipal } from "./authentication.js";
|
|
6
6
|
import type { AfterCommitHook } from "./config.js";
|
|
7
7
|
import type { ReplicationKick } from "./replication.js";
|
|
@@ -5,6 +5,7 @@ import { principalFromWire } from "../authoring.js";
|
|
|
5
5
|
import { PermissionDenied } from "../errors.js";
|
|
6
6
|
import { toCamelCase } from "../names.js";
|
|
7
7
|
import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
|
|
8
|
+
import { includedRelationships } from "../query.js";
|
|
8
9
|
import { CurrentPrincipal } from "./authentication.js";
|
|
9
10
|
import { AgentChannel } from "../agent-chat.js";
|
|
10
11
|
import { OIDC_TOKEN_HEADER } from "./connections.js";
|
|
@@ -18,7 +19,7 @@ import { InvalidActionInput, RequestId } from "./errors.js";
|
|
|
18
19
|
/** The query the runtime receives: every key present only when set, and no empty `include`. */
|
|
19
20
|
export function toQuery(decoded) {
|
|
20
21
|
const { include, ...rest } = decoded;
|
|
21
|
-
return
|
|
22
|
+
return includedRelationships(include).length > 0 ? { ...rest, include } : rest;
|
|
22
23
|
}
|
|
23
24
|
function page(result) {
|
|
24
25
|
return { items: result.items, nextCursor: result.nextCursor ?? null };
|
|
@@ -55,7 +56,7 @@ function filterReads(where, targets, reads) {
|
|
|
55
56
|
function queryReads(entity, query, targets) {
|
|
56
57
|
const own = targets.get(entity) ?? new Map();
|
|
57
58
|
const reads = new Set([entity]);
|
|
58
|
-
for (const name of
|
|
59
|
+
for (const name of includedRelationships(query.include)) {
|
|
59
60
|
const target = own.get(name);
|
|
60
61
|
if (target !== undefined) {
|
|
61
62
|
reads.add(target);
|
package/dist/service/schemas.js
CHANGED
|
@@ -194,7 +194,7 @@ function schemasOf(plan, planned, recordOf) {
|
|
|
194
194
|
include: Schema.optionalKey(includeParam(query.include)),
|
|
195
195
|
after: Schema.optionalKey(query.after),
|
|
196
196
|
limit: Schema.optionalKey(query.limit),
|
|
197
|
-
}, { orderBy: "order_by" }),
|
|
197
|
+
}, { orderBy: "order_by" }, "Unknown parameter"),
|
|
198
198
|
includeParams: {
|
|
199
199
|
include: Schema.optionalKey(includeParam(query.include)),
|
|
200
200
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
-
import { BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES } from "../blob.js";
|
|
3
|
+
import { acceptsBlobContentType, BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES } from "../blob.js";
|
|
4
4
|
import { ActionRejected, Conflict, NotFound } from "../errors.js";
|
|
5
5
|
import { canonicalJson } from "../manifest.js";
|
|
6
6
|
import { actionInputJsonSchema } from "../runtime/generated-actions.js";
|
|
@@ -357,7 +357,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
357
357
|
if (row.state !== "ready" || row.expired) {
|
|
358
358
|
return yield* unavailable("The attachment expired or was discarded.");
|
|
359
359
|
}
|
|
360
|
-
if (!property.accept
|
|
360
|
+
if (!acceptsBlobContentType(property.accept, row.content_type) ||
|
|
361
361
|
Number(row.size) > (property.maxBytes ?? MAX_BLOB_BYTES)) {
|
|
362
362
|
return yield* new ActionRejected({
|
|
363
363
|
message: `${field.entity}.${field.property} accepts ${(property.accept ?? []).join(", ")} ` +
|
package/dist/storage/sessions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
|
-
import { uploadInput, BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES } from "../blob.js";
|
|
3
|
+
import { acceptsBlobContentType, uploadInput, BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES, } from "../blob.js";
|
|
4
4
|
import { wireStruct } from "../wire.js";
|
|
5
5
|
import { ActionRejected, Conflict, NotFound } from "../errors.js";
|
|
6
6
|
import { authorizeBlobOperation } from "./authorization.js";
|
|
@@ -50,7 +50,7 @@ function allowsUpload(schema, entity, property) {
|
|
|
50
50
|
}
|
|
51
51
|
function sniff(bytes, contentType) {
|
|
52
52
|
const starts = (magic) => magic.every((byte, index) => bytes[index] === byte);
|
|
53
|
-
switch (contentType) {
|
|
53
|
+
switch (contentType.toLowerCase()) {
|
|
54
54
|
case "application/pdf":
|
|
55
55
|
return starts([37, 80, 68, 70, 45]);
|
|
56
56
|
case "image/png":
|
|
@@ -67,7 +67,8 @@ function sniff(bytes, contentType) {
|
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
69
|
default:
|
|
70
|
-
|
|
70
|
+
// Other MIME types describe opaque bytes; storage does not parse their formats.
|
|
71
|
+
return true;
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
/** Persisted phases surround every provider call; no external I/O holds a database transaction. */
|
|
@@ -108,7 +109,7 @@ export function blobRuntime(ontology, sql, provider) {
|
|
|
108
109
|
entity.source ||
|
|
109
110
|
!property ||
|
|
110
111
|
!found ||
|
|
111
|
-
!property.accept
|
|
112
|
+
!acceptsBlobContentType(property.accept, input.contentType) ||
|
|
112
113
|
input.size > (property.maxBytes ?? MAX_BLOB_BYTES) ||
|
|
113
114
|
Boolean(input.recordId) === Boolean(input.createIntent) ||
|
|
114
115
|
/[\x00-\x1f\x7f/\\]/.test(input.filename)) {
|
|
@@ -334,7 +335,8 @@ export function blobRuntime(ontology, sql, provider) {
|
|
|
334
335
|
key: blob.key,
|
|
335
336
|
filename: blob.filename,
|
|
336
337
|
contentType: blob.content_type,
|
|
337
|
-
inline: input.inline === true &&
|
|
338
|
+
inline: input.inline === true &&
|
|
339
|
+
["image/jpeg", "image/png"].includes(blob.content_type.toLowerCase()),
|
|
338
340
|
});
|
|
339
341
|
});
|
|
340
342
|
return { begin, status, part, complete, cancel, download };
|
package/dist/wire.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* `Unknown operator. Expected one of: eq, ne, in`: what a closed struct reports for a key it does
|
|
4
|
+
* not declare, so that a caller can correct the key without guessing. Long lists are cut short.
|
|
5
|
+
*/
|
|
6
|
+
export declare function expectedOneOf(problem: string, accepted: ReadonlyArray<string>): string;
|
|
2
7
|
/** A closed wire object with explicit key mappings and an unchanged decoded TypeScript shape. */
|
|
3
8
|
export declare function wireStruct<const F extends Schema.Struct.Fields, const M extends {
|
|
4
9
|
readonly [K in keyof F]?: string;
|
|
5
|
-
}>(fields: F, mapping: M): Schema.encodeKeys<Schema.Struct<F>, M>;
|
|
10
|
+
}>(fields: F, mapping: M, unknownKey?: string): Schema.encodeKeys<Schema.Struct<F>, M>;
|
package/dist/wire.js
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import { Schema, SchemaTransformation } from "effect";
|
|
2
|
+
/** An unknown-key message names at most this many accepted keys and counts the rest. */
|
|
3
|
+
const MAX_LISTED_KEYS = 30;
|
|
4
|
+
/**
|
|
5
|
+
* `Unknown operator. Expected one of: eq, ne, in`: what a closed struct reports for a key it does
|
|
6
|
+
* not declare, so that a caller can correct the key without guessing. Long lists are cut short.
|
|
7
|
+
*/
|
|
8
|
+
export function expectedOneOf(problem, accepted) {
|
|
9
|
+
if (accepted.length === 0) {
|
|
10
|
+
return `${problem}; there are none`;
|
|
11
|
+
}
|
|
12
|
+
const listed = accepted.slice(0, MAX_LISTED_KEYS).join(", ");
|
|
13
|
+
const rest = accepted.length - MAX_LISTED_KEYS;
|
|
14
|
+
return `${problem}. Expected one of: ${listed}${rest > 0 ? `, and ${rest} more` : ""}`;
|
|
15
|
+
}
|
|
2
16
|
/** A closed wire object with explicit key mappings and an unchanged decoded TypeScript shape. */
|
|
3
|
-
export function wireStruct(fields, mapping) {
|
|
17
|
+
export function wireStruct(fields, mapping, unknownKey = "Unknown key") {
|
|
4
18
|
const codec = Schema.Struct(fields).pipe(Schema.encodeKeys(mapping));
|
|
5
19
|
const encodedFields = Object.fromEntries(Object.entries(fields).map(([key, field]) => [
|
|
6
20
|
mapping[key] ?? key,
|
|
7
21
|
field,
|
|
8
22
|
]));
|
|
9
|
-
const
|
|
10
|
-
const unknown = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${
|
|
23
|
+
const encodedKeys = Object.keys(encodedFields);
|
|
24
|
+
const unknown = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${encodedKeys.join("|")})$)`)));
|
|
11
25
|
const reverse = Object.fromEntries(Object.entries(mapping).map(([key, value]) => [value, key]));
|
|
12
26
|
const rename = (value, keys) => Object.fromEntries(Object.entries(value).map(([key, child]) => [keys[key] ?? key, child]));
|
|
13
27
|
const encoded = Schema.StructWithRest(Schema.Struct(encodedFields), [
|
|
14
|
-
Schema.Record(unknown, Schema.Never.annotate({ message:
|
|
28
|
+
Schema.Record(unknown, Schema.Never.annotate({ message: expectedOneOf(unknownKey, encodedKeys) })),
|
|
15
29
|
]);
|
|
16
30
|
const decoded = Schema.toType(codec);
|
|
17
31
|
return encoded.pipe(Schema.decodeTo(decoded, SchemaTransformation.transform({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|