@segmentstream-cloud/ontology 0.9.0 → 0.10.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.
Files changed (52) hide show
  1. package/dist/analyze.js +16 -1
  2. package/dist/blob.d.ts +39 -0
  3. package/dist/blob.js +49 -0
  4. package/dist/entity.d.ts +5 -3
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +2 -0
  7. package/dist/manifest.d.ts +23 -7
  8. package/dist/manifest.js +9 -1
  9. package/dist/migrate/model.d.ts +7 -2
  10. package/dist/migrate/plan.js +26 -2
  11. package/dist/migrate/storage.d.ts +2 -0
  12. package/dist/migrate/storage.js +13 -0
  13. package/dist/property.d.ts +10 -1
  14. package/dist/property.js +12 -1
  15. package/dist/query.js +6 -1
  16. package/dist/runtime/generated-actions.js +7 -3
  17. package/dist/runtime/history.d.ts +3 -0
  18. package/dist/runtime/pipeline.d.ts +5 -1
  19. package/dist/runtime/pipeline.js +87 -3
  20. package/dist/runtime/records.js +9 -0
  21. package/dist/runtime/service-binding.d.ts +2 -1
  22. package/dist/runtime/service-binding.js +13 -2
  23. package/dist/runtime/writes.js +17 -3
  24. package/dist/service/api.js +15 -0
  25. package/dist/service/config.d.ts +3 -0
  26. package/dist/service/config.js +18 -0
  27. package/dist/service/handlers.js +40 -1
  28. package/dist/service/runtime-contract.d.ts +9 -0
  29. package/dist/service/schemas.d.ts +10 -2
  30. package/dist/service/schemas.js +18 -2
  31. package/dist/service/service.js +18 -3
  32. package/dist/storage/api.d.ts +4 -0
  33. package/dist/storage/api.js +39 -0
  34. package/dist/storage/authorization.d.ts +8 -0
  35. package/dist/storage/authorization.js +30 -0
  36. package/dist/storage/maintenance-auth.d.ts +20 -0
  37. package/dist/storage/maintenance-auth.js +24 -0
  38. package/dist/storage/maintenance.d.ts +7 -0
  39. package/dist/storage/maintenance.js +50 -0
  40. package/dist/storage/neon.d.ts +3 -0
  41. package/dist/storage/neon.js +212 -0
  42. package/dist/storage/operator.d.ts +10 -0
  43. package/dist/storage/operator.js +30 -0
  44. package/dist/storage/provider.d.ts +80 -0
  45. package/dist/storage/provider.js +20 -0
  46. package/dist/storage/sessions.d.ts +87 -0
  47. package/dist/storage/sessions.js +341 -0
  48. package/dist/storage/tables.d.ts +2 -0
  49. package/dist/storage/tables.js +55 -0
  50. package/dist/storage/writes.d.ts +7 -0
  51. package/dist/storage/writes.js +88 -0
  52. package/package.json +3 -1
@@ -1,4 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
+ import { uploadInput, BLOB_UPLOAD_ANNOTATION } from "../blob.js";
2
3
  import { action } from "../action.js";
3
4
  import { GENERATED_UPSERT_PREFIX } from "../names.js";
4
5
  import { generatedActionPermissions } from "../permissions.js";
@@ -16,7 +17,10 @@ export function upsertActionName(property) {
16
17
  return `${GENERATED_UPSERT_PREFIX}${property}`;
17
18
  }
18
19
  /** A property's value schema, refusing days that do not exist, which PostgreSQL would reject. */
19
- function propertyInput(field) {
20
+ function propertyInput(field, entity) {
21
+ if (field.storage === "blob") {
22
+ return uploadInput(entity.name, field.name);
23
+ }
20
24
  const schema = propertyValueSchema(field.property);
21
25
  if (field.storage === "date") {
22
26
  return schema.check(Schema.makeFilter((value) => isCalendarDate(value) || "Expected a date that exists"));
@@ -44,7 +48,7 @@ function valueFields(entity, { mode }) {
44
48
  if (mode === "update" && definition?.managed === true) {
45
49
  continue;
46
50
  }
47
- const value = propertyInput(field);
51
+ const value = propertyInput(field, entity);
48
52
  const required = mode === "create" &&
49
53
  !field.nullable &&
50
54
  !(definition !== undefined && "default" in definition);
@@ -164,7 +168,7 @@ function buildGeneratedActions(ontology, entityName) {
164
168
  }
165
169
  function inputJsonSchema(input) {
166
170
  const document = Schema.toJsonSchemaDocument(input, {
167
- includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION,
171
+ includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
168
172
  onExcessProperty: "error",
169
173
  });
170
174
  const schema = document.schema;
@@ -57,6 +57,9 @@ export interface HistoryScope {
57
57
  readonly action: string;
58
58
  readonly actionId: string;
59
59
  readonly principalId: string | null;
60
+ readonly branchId?: string;
61
+ readonly blobActionKey?: string;
62
+ readonly createIntent?: string;
60
63
  }
61
64
  /** `INSERT` of one history row; `at` is the transaction's time, shared by the whole action. */
62
65
  export declare function insertHistory(scope: HistoryScope, write: HistoryWrite, params: Params): string;
@@ -32,7 +32,10 @@ export interface ActionRequest<R = never> {
32
32
  readonly options?: {
33
33
  /** Decode the input and check the permission, without running `prepare` or the handler. */
34
34
  readonly validateOnly?: boolean;
35
+ readonly idempotencyKey?: string;
36
+ readonly createIntent?: string;
35
37
  };
38
+ readonly branchId?: string;
36
39
  readonly afterCommit?: ReadonlyArray<AfterCommit<R>>;
37
40
  /** The request's id, which `prepare` and the handler receive for logs; the action id by default. */
38
41
  readonly requestId?: string;
@@ -96,7 +99,8 @@ export declare function findAction(ontology: Ontology, entityName: string, actio
96
99
  * 1. decode the input with the action's schema; issues are returned, not raised;
97
100
  * 2. check that the principal's permissions cover the action's (PermissionDenied);
98
101
  * 3. with `validateOnly`, stop;
99
- * 4. run `prepare`, when the action has one, without a transaction: reads and the declared
102
+ * 4. reauthorize and replay a committed blob action, if its idempotency key matches;
103
+ * otherwise run `prepare`, when the action has one, without a transaction: reads and the declared
100
104
  * connections only, under {@link PREPARE_TIMEOUT};
101
105
  * 5. without a handler, answer with what `prepare` returned;
102
106
  * 6. open one transaction and run the handler with what `prepare` returned and the runtime, which
@@ -1,7 +1,10 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { Cause, Duration, Effect, Option, Schema } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
4
  import { isSqlError } from "effect/unstable/sql/SqlError";
5
+ import { canonicalJson } from "../manifest.js";
6
+ import { Conflict } from "../errors.js";
7
+ import { authorizeBlobOperation } from "../storage/authorization.js";
5
8
  import { ConnectionError, ConnectionTransport, connectionContext } from "../connection.js";
6
9
  import { PermissionDenied } from "../errors.js";
7
10
  import { generatedActionPermissions, missingPermission } from "../permissions.js";
@@ -61,7 +64,8 @@ const NO_CONNECTIONS = {
61
64
  * 1. decode the input with the action's schema; issues are returned, not raised;
62
65
  * 2. check that the principal's permissions cover the action's (PermissionDenied);
63
66
  * 3. with `validateOnly`, stop;
64
- * 4. run `prepare`, when the action has one, without a transaction: reads and the declared
67
+ * 4. reauthorize and replay a committed blob action, if its idempotency key matches;
68
+ * otherwise run `prepare`, when the action has one, without a transaction: reads and the declared
65
69
  * connections only, under {@link PREPARE_TIMEOUT};
66
70
  * 5. without a handler, answer with what `prepare` returned;
67
71
  * 6. open one transaction and run the handler with what `prepare` returned and the runtime, which
@@ -107,6 +111,50 @@ export function runAction(ontology, request) {
107
111
  const actionId = randomUUID();
108
112
  const requestId = request.requestId ?? actionId;
109
113
  const context = { input: decoded.value, principal: request.principal, requestId };
114
+ const hasUploads = (value) => typeof value === "object" &&
115
+ value !== null &&
116
+ ("uploadId" in value || Object.values(value).some(hasUploads));
117
+ const key = request.options?.idempotencyKey;
118
+ const hasBlobProperties = ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"));
119
+ const consumesUpload = action.transactional && hasBlobProperties && (hasUploads(decoded.value) || key !== undefined);
120
+ if (consumesUpload && (!key || key.length > 128 || !request.branchId)) {
121
+ return yield* new Conflict({
122
+ entity: request.entity,
123
+ message: "Upload actions require an idempotency key.",
124
+ });
125
+ }
126
+ const fingerprint = consumesUpload
127
+ ? createHash("sha256")
128
+ .update(canonicalJson({
129
+ qualified,
130
+ manifestHash: ontology.manifest.hash,
131
+ branchId: request.branchId ?? null,
132
+ input: request.input,
133
+ createIntent: request.options?.createIntent ?? null,
134
+ }))
135
+ .digest("hex")
136
+ : undefined;
137
+ // A committed retry must not depend on preparation's external services being available.
138
+ // The handler transaction below still locks and rechecks the key for concurrent requests.
139
+ if (consumesUpload) {
140
+ const saved = yield* sql
141
+ .withTransaction(Effect.gen(function* () {
142
+ yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
143
+ const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results"
144
+ WHERE principal_id = $1::uuid AND key = $2`, [request.principal.id, key]);
145
+ if (saved && saved.fingerprint !== fingerprint) {
146
+ return yield* new Conflict({
147
+ entity: request.entity,
148
+ message: "Idempotency key was used for another action.",
149
+ });
150
+ }
151
+ return saved?.outcome;
152
+ }))
153
+ .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
154
+ if (saved) {
155
+ return saved;
156
+ }
157
+ }
110
158
  const prepared = action.prepare === undefined
111
159
  ? undefined
112
160
  : yield* prepare(ontology, sql, action, context, request.connections, qualified);
@@ -118,13 +166,49 @@ export function runAction(ontology, request) {
118
166
  action: qualified,
119
167
  actionId,
120
168
  principalId: request.principal.id,
169
+ ...(key ? { blobActionKey: key } : {}),
170
+ ...(request.branchId ? { branchId: request.branchId } : {}),
171
+ ...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
121
172
  onEdit: (edit) => edits.push(edit),
122
173
  });
123
174
  const handle = action.handler;
124
175
  const handler = Effect.suspend(() => handle({ ...context, prepared }));
176
+ let replay;
177
+ const transactionalHandler = Effect.gen(function* () {
178
+ if (consumesUpload) {
179
+ yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
180
+ yield* sql.unsafe(`INSERT INTO "_blob_action_results" (principal_id, key, fingerprint)
181
+ VALUES ($1::uuid, $2, $3) ON CONFLICT DO NOTHING`, [request.principal.id, key, fingerprint]);
182
+ const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results" WHERE principal_id = $1::uuid AND key = $2
183
+ FOR UPDATE`, [request.principal.id, key]);
184
+ if (saved.fingerprint !== fingerprint) {
185
+ return yield* new Conflict({
186
+ entity: request.entity,
187
+ message: "Idempotency key was used for another action.",
188
+ });
189
+ }
190
+ if (saved.outcome) {
191
+ replay = saved.outcome;
192
+ return replay.result;
193
+ }
194
+ }
195
+ const result = yield* handler.pipe(OntologyRuntime.provide(runtime));
196
+ if (consumesUpload) {
197
+ yield* sql.unsafe(`UPDATE "_blob_action_results" SET outcome = $1::jsonb
198
+ WHERE principal_id = $2::uuid AND key = $3`, [
199
+ JSON.stringify({ result: result ?? null, validation: VALID, edits }),
200
+ request.principal.id,
201
+ key,
202
+ ]);
203
+ }
204
+ return result;
205
+ });
125
206
  const result = yield* sql
126
- .withTransaction(handler.pipe(OntologyRuntime.provide(runtime)))
207
+ .withTransaction(transactionalHandler)
127
208
  .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
209
+ if (replay) {
210
+ return replay;
211
+ }
128
212
  const committed = {
129
213
  entity: request.entity,
130
214
  action: request.action,
@@ -12,6 +12,11 @@ import { validate } from "./validation.js";
12
12
  */
13
13
  function readExpression(field, column) {
14
14
  switch (field.storage) {
15
+ case "blob":
16
+ return (`(SELECT json_build_object('id', b.id::text, 'filename', b.filename, ` +
17
+ `'contentType', b.content_type, 'size', b.size, 'createdAt', ` +
18
+ `to_char(b.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))::text ` +
19
+ `FROM "_blobs" b WHERE b.id = ${column})`);
15
20
  case "timestamp":
16
21
  return `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
17
22
  case "date":
@@ -42,6 +47,7 @@ function readValue(field, value) {
42
47
  switch (field.storage) {
43
48
  case "integer":
44
49
  return Number(value);
50
+ case "blob":
45
51
  case "json":
46
52
  return typeof value === "string" ? JSON.parse(value) : value;
47
53
  case "timestamp":
@@ -76,6 +82,9 @@ export function bindValue(field, value) {
76
82
  if (value === null) {
77
83
  return null;
78
84
  }
85
+ if (field.storage === "blob") {
86
+ return typeof value === "string" ? value : value.id;
87
+ }
79
88
  return field.storage === "json" ? JSON.stringify(value) : value;
80
89
  }
81
90
  function propertyIssue(field, value) {
@@ -1,10 +1,11 @@
1
1
  import { Layer, Redacted } from "effect";
2
2
  import { SqlClient } from "effect/unstable/sql";
3
3
  import type { SqlError } from "effect/unstable/sql/SqlError";
4
+ import type { BlobStorageBinding } from "../storage/provider.js";
4
5
  import type { Ontology } from "../ontology.js";
5
6
  import { OntologyBackend, type OntologyServiceRuntime } from "../service/runtime-contract.js";
6
7
  /** The service's backend on the runtime, over the SQL client in context. */
7
- export declare function backendLayer(ontology: Ontology): Layer.Layer<OntologyBackend, never, SqlClient.SqlClient>;
8
+ export declare function backendLayer(ontology: Ontology, blobStorage?: Redacted.Redacted<BlobStorageBinding>): Layer.Layer<OntologyBackend, never, SqlClient.SqlClient>;
8
9
  /**
9
10
  * `@effect/sql-pg` on the pooled `ontology_app` connection string, with the TLS server name a
10
11
  * Neon endpoint needs.
@@ -1,6 +1,9 @@
1
1
  import * as PgClient from "@effect/sql-pg/PgClient";
2
2
  import { Effect, Layer, Redacted } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
+ import { neonBlobProvider } from "../storage/neon.js";
5
+ import { maintainBlobStorage } from "../storage/maintenance.js";
6
+ import { blobRuntime } from "../storage/sessions.js";
4
7
  import { readCatalog } from "../migrate/apply.js";
5
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
6
9
  import { generatedActions } from "./generated-actions.js";
@@ -18,18 +21,26 @@ function rejectedAs(operation) {
18
21
  return (rejection) => Effect.logWarning("ontology.principal.rejected").pipe(Effect.annotateLogs({ operation, reason: rejection.reason }), Effect.as(undefined));
19
22
  }
20
23
  /** The service's backend on the runtime, over the SQL client in context. */
21
- export function backendLayer(ontology) {
24
+ export function backendLayer(ontology, blobStorage) {
22
25
  return Layer.effect(OntologyBackend, Effect.gen(function* () {
23
26
  const sql = yield* SqlClient.SqlClient;
24
27
  const runtime = makeOntologyRuntime(ontology, sql);
25
28
  const withSql = (effect) => Effect.provideService(effect, SqlClient.SqlClient, sql);
29
+ const binding = blobStorage ? Redacted.value(blobStorage) : undefined;
30
+ const provider = binding
31
+ ? yield* Effect.acquireRelease(Effect.sync(() => neonBlobProvider(binding)), (provider) => Effect.sync(() => provider.close?.()))
32
+ : undefined;
33
+ const blobs = provider ? blobRuntime(ontology, sql, provider) : undefined;
26
34
  return OntologyBackend.of({
35
+ ...(blobs ? { blobs } : {}),
36
+ ...(provider ? { maintainBlobs: maintainBlobStorage(sql, provider) } : {}),
27
37
  runAction: (input) => withSql(runAction(ontology, {
28
38
  entity: input.entity,
29
39
  action: input.action,
30
40
  input: input.input,
31
41
  principal: input.principal,
32
42
  options: input.options,
43
+ ...(binding ? { branchId: binding.branchId } : {}),
33
44
  afterCommit: input.afterCommit === undefined ? [] : [input.afterCommit],
34
45
  ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
35
46
  ...(input.connections === undefined ? {} : { connections: input.connections }),
@@ -66,5 +77,5 @@ export function postgresLayer(databaseUrl) {
66
77
  /** The default runtime of `createOntologyService`: the SQL runtime on the workspace database. */
67
78
  export const sqlServiceRuntime = {
68
79
  generatedActions: (entity, ontology) => generatedActions(ontology, entity.name),
69
- connect: ({ ontology, databaseUrl }) => backendLayer(ontology).pipe(Layer.provide(postgresLayer(databaseUrl))),
80
+ connect: ({ ontology, databaseUrl, blobStorage }) => backendLayer(ontology, blobStorage).pipe(Layer.provide(postgresLayer(databaseUrl))),
70
81
  };
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { resolveBlobWrites } from "../storage/writes.js";
2
3
  import { Conflict } from "../errors.js";
3
4
  import { quoteIdentifier } from "../migrate/storage.js";
4
5
  import { InvalidValues } from "./errors.js";
@@ -21,7 +22,9 @@ function requireReferences(context, references) {
21
22
  }
22
23
  function insertRecord(context, entity, values, onConflict) {
23
24
  return Effect.gen(function* () {
24
- const write = yield* prepareWrite(entity, values, "create");
25
+ const recordId = uuidv7();
26
+ const resolved = yield* resolveBlobWrites(context, entity, recordId, values, true);
27
+ const write = yield* prepareWrite(entity, resolved, "create");
25
28
  yield* requireReferences(context, write.references);
26
29
  const params = new Params();
27
30
  const principal = params.add(context.scope.principalId, "uuid");
@@ -34,7 +37,7 @@ function insertRecord(context, entity, values, onConflict) {
34
37
  ...write.columns.map(({ field }) => quoteIdentifier(field.column)),
35
38
  ];
36
39
  const placeholders = [
37
- params.add(uuidv7(), "uuid"),
40
+ params.add(recordId, "uuid"),
38
41
  "now()",
39
42
  "now()",
40
43
  principal,
@@ -64,11 +67,12 @@ export function createRecord(context, entity, values) {
64
67
  }
65
68
  export function updateRecord(context, entity, id, values) {
66
69
  return Effect.gen(function* () {
67
- const write = yield* prepareWrite(entity, values, "update");
68
70
  const before = yield* loadRecord(context, entity, id, "update");
69
71
  if (before === undefined) {
70
72
  return yield* notFound(entity, id);
71
73
  }
74
+ const resolved = yield* resolveBlobWrites(context, entity, id, values, false);
75
+ const write = yield* prepareWrite(entity, resolved, "update");
72
76
  yield* requireReferences(context, write.references);
73
77
  let after = before;
74
78
  if (write.columns.length > 0) {
@@ -121,6 +125,16 @@ export function upsertRecord(context, entity, property, values) {
121
125
  issues: [{ path: [property], message: `${property} is required` }],
122
126
  }));
123
127
  }
128
+ // A file ticket for an existing record must not be consumed by a speculative INSERT.
129
+ if (entity.fields.some((candidate) => candidate.storage === "blob" && values[candidate.name] != null)) {
130
+ const lookup = new Params();
131
+ const [existing] = yield* context.run(`SELECT id::text AS id FROM ${quoteIdentifier(entity.table)}
132
+ WHERE ${quoteIdentifier(field.column)} = ${lookup.add(bindValue(field, key), field.sqlType)} FOR UPDATE`, lookup);
133
+ if (existing) {
134
+ const record = yield* updateRecord(context, entity, existing.id, values);
135
+ return { record, created: false };
136
+ }
137
+ }
124
138
  const created = yield* insertRecord(context, entity, values, ` ON CONFLICT (${quoteIdentifier(field.column)}) DO NOTHING`);
125
139
  if (created !== undefined) {
126
140
  return { record: created, created: true };
@@ -1,5 +1,7 @@
1
1
  import { Effect, Layer, Schema } from "effect";
2
2
  import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi, } from "effect/unstable/httpapi";
3
+ import { storageGroup } from "../storage/api.js";
4
+ import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
3
5
  import { permissionOf, READ_VERB } from "../permissions.js";
4
6
  import { generatedActions } from "../runtime/generated-actions.js";
5
7
  import { Authentication } from "./authentication.js";
@@ -121,6 +123,14 @@ function entityGroup(planned, schemas, names) {
121
123
  error: recordErrors,
122
124
  }), `History of a ${definition.label.toLowerCase()}`, `What each action changed on the record, newest first. ${read}`),
123
125
  ...planned.traversals.map((traversal) => traversalEndpoint(planned, traversal, schemas.get(traversal.target), names.get(traversal.target) ?? { label: traversal.target, plural: traversal.target })),
126
+ ...planned.resolved.properties
127
+ .filter((property) => property.definition.type === "blob")
128
+ .map((property) => documented(HttpApiEndpoint.post(`blobs.${property.name}`, `${base}/:id/blobs/${property.name}/download`, {
129
+ params: { id: IdParam },
130
+ payload: DownloadRequest,
131
+ success: TransferGrant,
132
+ error: recordErrors,
133
+ }), `Download ${property.name}`, `Requires ${planned.name}:read. Historical revisions must belong to this record and field.`)),
124
134
  ...planned.actions.map((action) => actionEndpoint(planned, action)),
125
135
  ];
126
136
  const [first, ...rest] = endpoints.map(authenticated);
@@ -172,6 +182,11 @@ export function buildApi(plan) {
172
182
  ]));
173
183
  const groups = [
174
184
  serviceGroup(),
185
+ ...(plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
186
+ ? [
187
+ storageGroup(authenticated, actionErrors, (endpoint) => endpoint.middleware(ErrorResponses)),
188
+ ]
189
+ : []),
175
190
  ...plan.entities.map((planned) => entityGroup(planned, schemas, names)),
176
191
  ];
177
192
  const api = HttpApi.make("ontology")
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import type { Principal } from "../runtime.js";
3
4
  import { type ConnectionTransportFactory, type LocalConnectionContext } from "./connections.js";
4
5
  import type { OntologyServiceRuntime } from "./runtime-contract.js";
@@ -29,6 +30,8 @@ export interface NeonAuthEnvironment {
29
30
  export interface OntologyEnvironment {
30
31
  /** The pooled connection string of the `ontology_app` role. */
31
32
  readonly databaseUrl: Redacted.Redacted<string>;
33
+ readonly blobStorage?: Redacted.Redacted<BlobStorageBinding>;
34
+ readonly storageMaintenanceKey?: Redacted.Redacted<string>;
32
35
  /** The control plane: it issues app tokens and serves their JWKS. */
33
36
  readonly segmentstreamUrl: string;
34
37
  /** App tokens must be issued for this application: `aud: app:<application id>`. */
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import { LocalConnectionContextError, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./connections.js";
3
4
  /** The environment of the workspace deployment's ontology service. */
4
5
  export const ENVIRONMENT_VARIABLES = {
@@ -83,12 +84,29 @@ export function configFromEnv(environment = process.env) {
83
84
  }
84
85
  const neonAuth = neonAuthFromEnv(read);
85
86
  const localConnections = localConnectionsFromEnv(environment);
87
+ let blobStorage;
88
+ if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
89
+ try {
90
+ blobStorage = Redacted.make(Schema.decodeUnknownSync(BlobStorageBinding)(JSON.parse(read("SEGMENTSTREAM_ONTOLOGY_STORAGE"))));
91
+ }
92
+ catch {
93
+ throw new OntologyServiceConfigError({
94
+ message: "SEGMENTSTREAM_ONTOLOGY_STORAGE is invalid.",
95
+ });
96
+ }
97
+ }
86
98
  return {
87
99
  databaseUrl: checkDatabaseUrl(ENVIRONMENT_VARIABLES.databaseUrl, read(ENVIRONMENT_VARIABLES.databaseUrl)),
88
100
  segmentstreamUrl: normalizeUrl(ENVIRONMENT_VARIABLES.segmentstreamUrl, read(ENVIRONMENT_VARIABLES.segmentstreamUrl)),
89
101
  applicationId: read(ENVIRONMENT_VARIABLES.applicationId),
90
102
  workspaceId: read(ENVIRONMENT_VARIABLES.workspaceId),
91
103
  organizationId: read(ENVIRONMENT_VARIABLES.organizationId),
104
+ ...(blobStorage === undefined ? {} : { blobStorage }),
105
+ ...(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")
106
+ ? {
107
+ storageMaintenanceKey: Redacted.make(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")),
108
+ }
109
+ : {}),
92
110
  ...(neonAuth === undefined ? {} : { neonAuth }),
93
111
  ...(localConnections === undefined ? {} : { localConnections }),
94
112
  };
@@ -1,6 +1,8 @@
1
1
  import { HttpServerResponse } from "effect/unstable/http";
2
2
  import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
+ import { STORAGE_GROUP } from "../storage/api.js";
5
+ import { Conflict } from "../errors.js";
4
6
  import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
5
7
  import { nameToWire } from "../authoring-names.js";
6
8
  import { principalFromWire } from "../authoring.js";
@@ -181,7 +183,7 @@ function runAction(planned, { name, generated }, ontology, hook, connections) {
181
183
  action: name,
182
184
  input: envelope.input,
183
185
  principal,
184
- options: { validateOnly },
186
+ options: { ...envelope.options, validateOnly },
185
187
  requestId,
186
188
  connections: connections({
187
189
  identityToken: identityToken(request),
@@ -219,6 +221,18 @@ function entityHandlers(built, planned, hooks, targets, connections) {
219
221
  .handle("query", (request) => find(toQuery(request.payload)))
220
222
  .handle("get", (request) => readOwn.pipe(Effect.andThen(() => backend.get({ entity: planned.name, id: request.params.id }))))
221
223
  .handle("history", (request) => readOwn.pipe(Effect.andThen(() => backend.history({ entity: planned.name, id: request.params.id, ...request.query })), Effect.map(page)));
224
+ for (const property of planned.resolved.properties.filter((property) => property.definition.type === "blob")) {
225
+ registered = registered.handle(`blobs.${property.name}`, (request) => Effect.gen(function* () {
226
+ const principal = yield* CurrentPrincipal;
227
+ if (!backend.blobs) {
228
+ return yield* new Conflict({
229
+ entity: planned.name,
230
+ message: "File storage is not configured.",
231
+ });
232
+ }
233
+ return yield* backend.blobs.download(planned.name, request.params.id, property.name, request.payload, principal);
234
+ }));
235
+ }
222
236
  for (const traversal of planned.traversals) {
223
237
  registered = registered.handle(`related.${traversal.name}`, (request) => traverse(planned, traversal, request, targets).pipe(Effect.provideService(OntologyBackend, backend)));
224
238
  }
@@ -324,11 +338,36 @@ function serviceHandlers(built) {
324
338
  .handle("signInCheck", checkSignIn(backend, signInLimiter));
325
339
  }));
326
340
  }
341
+ function storageHandlers(built) {
342
+ return dynamicGroup(built.api, STORAGE_GROUP, (handlers) => Effect.gen(function* () {
343
+ const backend = yield* OntologyBackend;
344
+ const configured = () => backend.blobs
345
+ ? Effect.succeed(backend.blobs)
346
+ : Effect.fail(new Conflict({ entity: "upload", message: "File storage is not configured." }));
347
+ let registered = handlers.handle("begin", (request) => Effect.gen(function* () {
348
+ const principal = yield* CurrentPrincipal;
349
+ const blobs = yield* configured();
350
+ return yield* blobs.begin(request.payload, principal);
351
+ }));
352
+ for (const method of ["status", "part", "complete", "cancel"]) {
353
+ registered = registered.handle(method, (request) => Effect.gen(function* () {
354
+ const principal = yield* CurrentPrincipal;
355
+ const blobs = yield* configured();
356
+ return yield* blobs[method](request.params.id, principal);
357
+ }));
358
+ }
359
+ return registered.handle("maintain", () => backend.maintainBlobs ??
360
+ Effect.fail(new Conflict({ entity: "upload", message: "File storage is not configured." })));
361
+ }));
362
+ }
327
363
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
328
364
  export function handlersLayer(built, hooks, connections) {
329
365
  const targets = relationshipTargets(built.plan);
330
366
  const layers = [
331
367
  serviceHandlers(built),
368
+ ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
369
+ ? [storageHandlers(built)]
370
+ : []),
332
371
  ...built.plan.entities.map((planned) => entityHandlers(built, planned, hooks, targets, connections)),
333
372
  ];
334
373
  const [first, ...rest] = layers;
@@ -1,4 +1,6 @@
1
1
  import { Context, Schema, type Effect, type Layer, type Redacted } from "effect";
2
+ import type { BlobRuntime } from "../storage/sessions.js";
3
+ import type { BlobStorageBinding } from "../storage/provider.js";
2
4
  import type { AnyAction } from "../action.js";
3
5
  import type { ResolvedEntity } from "../analyze.js";
4
6
  import type { ConnectionError, ConnectionTransport } from "../connection.js";
@@ -23,6 +25,8 @@ export interface RunActionInput {
23
25
  readonly principal: Principal;
24
26
  readonly options: {
25
27
  readonly validateOnly?: boolean;
28
+ readonly idempotencyKey?: string;
29
+ readonly createIntent?: string;
26
30
  };
27
31
  /**
28
32
  * Runs once the action has committed, outside its transaction, before `runAction` returns. It
@@ -63,6 +67,10 @@ declare const NotInvited_base: Schema.Class<NotInvited, Schema.TaggedStruct<"Not
63
67
  export declare class NotInvited extends NotInvited_base {
64
68
  }
65
69
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
70
+ readonly blobs?: BlobRuntime;
71
+ readonly maintainBlobs?: Effect.Effect<{
72
+ readonly removed: number;
73
+ }, unknown>;
66
74
  readonly runAction: (input: RunActionInput) => Effect.Effect<ActionOutcome, PermissionDenied | ActionRejected | Conflict | NotFound | ConnectionError>;
67
75
  readonly get: (input: {
68
76
  readonly entity: string;
@@ -111,6 +119,7 @@ export interface OntologyServiceRuntime {
111
119
  readonly connect: (input: {
112
120
  readonly ontology: Ontology;
113
121
  readonly databaseUrl: Redacted.Redacted<string>;
122
+ readonly blobStorage?: Redacted.Redacted<BlobStorageBinding>;
114
123
  }) => Layer.Layer<OntologyBackend, unknown>;
115
124
  }
116
125
  export {};
@@ -124,6 +124,8 @@ export declare const ActionEnvelope: Schema.Codec<{
124
124
  readonly input: unknown;
125
125
  readonly options?: {
126
126
  readonly validateOnly?: boolean;
127
+ readonly idempotencyKey?: string;
128
+ readonly createIntent?: string;
127
129
  };
128
130
  }>;
129
131
  export declare const ActionResponse: Schema.Struct<{
@@ -151,7 +153,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
151
153
  readonly column: Schema.optionalKey<Schema.String>;
152
154
  readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
153
155
  readonly name: Schema.String;
154
- readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json"]>;
156
+ readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"]>;
155
157
  readonly label: Schema.String;
156
158
  readonly description: Schema.optionalKey<Schema.String>;
157
159
  readonly optional: Schema.Boolean;
@@ -163,6 +165,8 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
163
165
  readonly min: Schema.optionalKey<Schema.Number>;
164
166
  readonly max: Schema.optionalKey<Schema.Number>;
165
167
  readonly maxLength: Schema.optionalKey<Schema.Number>;
168
+ readonly accept: Schema.optionalKey<Schema.$Array<Schema.String>>;
169
+ readonly maxBytes: Schema.optionalKey<Schema.Number>;
166
170
  readonly options: Schema.optionalKey<Schema.$Array<Schema.Struct<{
167
171
  readonly value: Schema.String;
168
172
  readonly label: Schema.String;
@@ -171,6 +175,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
171
175
  }>, {
172
176
  readonly renamedFrom: "renamed_from";
173
177
  readonly maxLength: "max_length";
178
+ readonly maxBytes: "max_bytes";
174
179
  }>>;
175
180
  readonly relationships: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
176
181
  readonly on: Schema.optionalKey<Schema.encodeKeys<Schema.Struct<{
@@ -259,7 +264,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
259
264
  readonly column: Schema.optionalKey<Schema.String>;
260
265
  readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
261
266
  readonly name: Schema.String;
262
- readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json"]>;
267
+ readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"]>;
263
268
  readonly label: Schema.String;
264
269
  readonly description: Schema.optionalKey<Schema.String>;
265
270
  readonly optional: Schema.Boolean;
@@ -271,6 +276,8 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
271
276
  readonly min: Schema.optionalKey<Schema.Number>;
272
277
  readonly max: Schema.optionalKey<Schema.Number>;
273
278
  readonly maxLength: Schema.optionalKey<Schema.Number>;
279
+ readonly accept: Schema.optionalKey<Schema.$Array<Schema.String>>;
280
+ readonly maxBytes: Schema.optionalKey<Schema.Number>;
274
281
  readonly options: Schema.optionalKey<Schema.$Array<Schema.Struct<{
275
282
  readonly value: Schema.String;
276
283
  readonly label: Schema.String;
@@ -279,6 +286,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
279
286
  }>, {
280
287
  readonly renamedFrom: "renamed_from";
281
288
  readonly maxLength: "max_length";
289
+ readonly maxBytes: "max_bytes";
282
290
  }>>;
283
291
  readonly relationships: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
284
292
  readonly on: Schema.optionalKey<Schema.encodeKeys<Schema.Struct<{
@@ -1,3 +1,4 @@
1
+ import { BlobValue } from "../blob.js";
1
2
  import { Schema, SchemaTransformation } from "effect";
2
3
  import { wireStruct } from "../wire.js";
3
4
  import { OntologyManifestWire } from "../manifest.js";
@@ -68,6 +69,8 @@ function propertyValue(definition) {
68
69
  }
69
70
  case "json":
70
71
  return Schema.Json.annotate(documentation);
72
+ case "blob":
73
+ return BlobValue.annotate(documentation);
71
74
  }
72
75
  }
73
76
  function recordFields(entity) {
@@ -226,11 +229,20 @@ export const HistoryPage = Schema.Struct({
226
229
  })
227
230
  .pipe(Schema.encodeKeys({ nextCursor: "next_cursor" }))
228
231
  .annotate({ identifier: "HistoryPage" });
232
+ const uploadOptions = {
233
+ idempotencyKey: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128))),
234
+ createIntent: Schema.optionalKey(Schema.String.check(Schema.isUUID())),
235
+ };
229
236
  const ActionOptions = wireStruct({
237
+ ...uploadOptions,
230
238
  validateOnly: Schema.optionalKey(Schema.Boolean.annotate({
231
239
  description: "Validate the input and report issues without a write.",
232
240
  })),
233
- }, { validateOnly: "validate_only" });
241
+ }, {
242
+ validateOnly: "validate_only",
243
+ idempotencyKey: "idempotency_key",
244
+ createIntent: "create_intent",
245
+ });
234
246
  /** The body of `POST /<entity>/actions/<action>`, as documented. */
235
247
  export function actionRequest(input) {
236
248
  return Schema.Struct({ input, options: Schema.optionalKey(ActionOptions) });
@@ -238,7 +250,11 @@ export function actionRequest(input) {
238
250
  /** The body of `POST /<entity>/actions/<action>`, as decoded: `runAction` validates the input. */
239
251
  export const ActionEnvelope = closedStruct({
240
252
  input: Schema.Unknown,
241
- options: Schema.optionalKey(wireStruct({ validateOnly: Schema.optionalKey(Schema.Boolean) }, { validateOnly: "validate_only" })),
253
+ options: Schema.optionalKey(wireStruct({ validateOnly: Schema.optionalKey(Schema.Boolean), ...uploadOptions }, {
254
+ validateOnly: "validate_only",
255
+ idempotencyKey: "idempotency_key",
256
+ createIntent: "create_intent",
257
+ })),
242
258
  }, "Unknown key; use input and options");
243
259
  export const ActionResponse = Schema.Struct({
244
260
  result: Schema.Unknown.annotate({ description: "What the action returned." }),