@segmentstream-cloud/ontology 0.17.0 → 0.18.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/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 { BLOB_CONTENT_TYPES, MAX_BLOB_BYTES } from "./blob.js";
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) => !BLOB_CONTENT_TYPES.includes(type)) ||
415
- new Set(definition.accept).size !== definition.accept.length ||
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 supported content types and a limit from 1 byte to 25 MiB.");
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") {
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
- export declare const BLOB_CONTENT_TYPES: readonly ["application/pdf", "image/jpeg", "image/png", "text/plain"];
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);
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- import { BlobValue, type BLOB_CONTENT_TYPES } from "./blob.js";
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
- readonly accept: ReadonlyArray<(typeof BLOB_CONTENT_TYPES)[number]>;
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: O): PropertyOf<BlobValue, O>;
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: { ...options, maxBytes: options.maxBytes ?? MAX_BLOB_BYTES },
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 = {
@@ -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?.includes(row.content_type) ||
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(", ")} ` +
@@ -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
- return false;
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?.includes(input.contentType) ||
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 && blob.content_type.startsWith("image/"),
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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",