@segmentstream-cloud/ontology 0.8.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 (61) hide show
  1. package/README.md +7 -1
  2. package/dist/analyze.js +16 -1
  3. package/dist/blob.d.ts +39 -0
  4. package/dist/blob.js +49 -0
  5. package/dist/entity.d.ts +5 -3
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +2 -0
  8. package/dist/manifest.d.ts +23 -7
  9. package/dist/manifest.js +9 -1
  10. package/dist/migrate/model.d.ts +7 -2
  11. package/dist/migrate/plan.js +26 -2
  12. package/dist/migrate/storage.d.ts +2 -0
  13. package/dist/migrate/storage.js +13 -0
  14. package/dist/names.d.ts +1 -1
  15. package/dist/names.js +1 -0
  16. package/dist/property.d.ts +10 -1
  17. package/dist/property.js +12 -1
  18. package/dist/query.js +6 -1
  19. package/dist/runtime/generated-actions.js +7 -3
  20. package/dist/runtime/history.d.ts +3 -0
  21. package/dist/runtime/pipeline.d.ts +5 -1
  22. package/dist/runtime/pipeline.js +87 -3
  23. package/dist/runtime/records.js +9 -0
  24. package/dist/runtime/service-binding.d.ts +2 -1
  25. package/dist/runtime/service-binding.js +13 -2
  26. package/dist/runtime/writes.js +17 -3
  27. package/dist/service/api.js +15 -0
  28. package/dist/service/authentication.d.ts +33 -4
  29. package/dist/service/authentication.js +142 -6
  30. package/dist/service/config.d.ts +3 -0
  31. package/dist/service/config.js +18 -0
  32. package/dist/service/docs.d.ts +9 -0
  33. package/dist/service/docs.js +197 -0
  34. package/dist/service/errors.d.ts +5 -0
  35. package/dist/service/errors.js +14 -0
  36. package/dist/service/handlers.js +40 -1
  37. package/dist/service/runtime-contract.d.ts +9 -0
  38. package/dist/service/schemas.d.ts +10 -2
  39. package/dist/service/schemas.js +18 -2
  40. package/dist/service/service.js +22 -5
  41. package/dist/storage/api.d.ts +4 -0
  42. package/dist/storage/api.js +39 -0
  43. package/dist/storage/authorization.d.ts +8 -0
  44. package/dist/storage/authorization.js +30 -0
  45. package/dist/storage/maintenance-auth.d.ts +20 -0
  46. package/dist/storage/maintenance-auth.js +24 -0
  47. package/dist/storage/maintenance.d.ts +7 -0
  48. package/dist/storage/maintenance.js +50 -0
  49. package/dist/storage/neon.d.ts +3 -0
  50. package/dist/storage/neon.js +212 -0
  51. package/dist/storage/operator.d.ts +10 -0
  52. package/dist/storage/operator.js +30 -0
  53. package/dist/storage/provider.d.ts +80 -0
  54. package/dist/storage/provider.js +20 -0
  55. package/dist/storage/sessions.d.ts +87 -0
  56. package/dist/storage/sessions.js +341 -0
  57. package/dist/storage/tables.d.ts +2 -0
  58. package/dist/storage/tables.js +55 -0
  59. package/dist/storage/writes.d.ts +7 -0
  60. package/dist/storage/writes.js +88 -0
  61. package/package.json +3 -1
package/dist/property.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { humanize } from "./names.js";
3
+ import { BlobValue, MAX_BLOB_BYTES } from "./blob.js";
3
4
  import { nameToWire } from "./authoring-names.js";
4
5
  /** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
5
6
  export const PROPERTY_TYPES = [
@@ -13,6 +14,7 @@ export const PROPERTY_TYPES = [
13
14
  "timestamp",
14
15
  "select",
15
16
  "json",
17
+ "blob",
16
18
  ];
17
19
  /** Schema annotation that carries a property's storage metadata. */
18
20
  export const PROPERTY_ANNOTATION = "segmentstream/ontology/property";
@@ -47,6 +49,8 @@ function construct({ type, base, options, checks = [], format, selectOptions, })
47
49
  ...(options.min !== undefined ? { min: options.min } : {}),
48
50
  ...(options.max !== undefined ? { max: options.max } : {}),
49
51
  ...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}),
52
+ ...(options.accept !== undefined ? { accept: options.accept } : {}),
53
+ ...(options.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}),
50
54
  ...(selectOptions !== undefined ? { options: selectOptions } : {}),
51
55
  };
52
56
  // Annotations go on the base node, before the checks, so that the JSON Schema keeps them at the top
@@ -176,7 +180,13 @@ function ref(target, options) {
176
180
  }).check(Schema.isPattern(UUID));
177
181
  return (settings.optional === true ? Schema.optionalKey(schema) : schema);
178
182
  }
179
- /** Property constructors. */
183
+ function blob(options) {
184
+ return construct({
185
+ type: "blob",
186
+ base: BlobValue,
187
+ options: { ...options, maxBytes: options.maxBytes ?? MAX_BLOB_BYTES },
188
+ });
189
+ }
180
190
  export const p = {
181
191
  text,
182
192
  email,
@@ -188,6 +198,7 @@ export const p = {
188
198
  timestamp,
189
199
  select,
190
200
  json,
201
+ blob,
191
202
  ref,
192
203
  };
193
204
  function findAnnotation(ast, key) {
package/dist/query.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { wireStruct } from "./wire.js";
3
+ import { BlobValue } from "./blob.js";
3
4
  /**
4
5
  * The query language of `find` and of the HTTP API's list and query endpoints.
5
6
  *
@@ -194,12 +195,13 @@ export function fieldValueSchema(field) {
194
195
  case "json":
195
196
  return JsonValue;
196
197
  case "uuid":
198
+ case "blob":
197
199
  return UuidValue;
198
200
  }
199
201
  }
200
202
  /** Fields a query can sort on: every field but `json` properties. */
201
203
  export function isOrderable(field) {
202
- return field.storage !== "json";
204
+ return field.storage !== "json" && field.storage !== "blob";
203
205
  }
204
206
  const FILTER_IDENTIFIERS = {
205
207
  text: "TextFilter",
@@ -212,6 +214,7 @@ const FILTER_IDENTIFIERS = {
212
214
  timestamp: "TimestampFilter",
213
215
  json: "JsonFilter",
214
216
  uuid: "IdFilter",
217
+ blob: "IdFilter",
215
218
  };
216
219
  const FILTER_DESCRIPTIONS = {
217
220
  TextFilter: "contains and startsWith ignore case.",
@@ -413,6 +416,8 @@ function recordValueSchema(field) {
413
416
  return Schema.Literals(field.options ?? []);
414
417
  case "json":
415
418
  return Schema.Json;
419
+ case "blob":
420
+ return BlobValue;
416
421
  default:
417
422
  return Schema.String;
418
423
  }
@@ -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")
@@ -23,8 +23,8 @@ declare const Authentication_base: HttpApiMiddleware.ServiceClass<Authentication
23
23
  }, CurrentPrincipal, readonly [import("effect/Schema").Top, import("effect/Schema").Top, import("effect/Schema").Top], RequestId>>;
24
24
  /**
25
25
  * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
26
- * or an API key, all as `Authorization: Bearer …`. Failures answer the request directly, before its
27
- * input is decoded.
26
+ * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
27
+ * Failures answer the request directly, before its input is decoded.
28
28
  */
29
29
  export declare class Authentication extends Authentication_base {
30
30
  }
@@ -79,6 +79,32 @@ export declare function verifyByIssuer(verifiers: ReadonlyMap<string, TokenVerif
79
79
  export declare function tokenVerifier(config: TokenVerifierConfig & {
80
80
  readonly neonAuth?: NeonAuthEnvironment | undefined;
81
81
  }): TokenVerifier;
82
+ /**
83
+ * The session cookie the app's Neon Auth sets on the app's domain. The ontology service shares the
84
+ * domain, so a signed-in browser sends it here too: the docs page relies on it.
85
+ */
86
+ export declare const NEON_AUTH_SESSION_COOKIE = "__Secure-neon-auth.session_token";
87
+ export type SessionExchangeResult = {
88
+ readonly status: "token";
89
+ readonly token: string;
90
+ } | {
91
+ readonly status: "invalid";
92
+ } | {
93
+ readonly status: "unavailable";
94
+ };
95
+ /** Turns a Neon Auth session cookie into the session's token; `origin` is the app's. */
96
+ export type SessionExchange = (session: Redacted.Redacted<string>, origin: string) => Promise<SessionExchangeResult>;
97
+ /**
98
+ * Exchanges a session cookie for its token at Neon Auth's `GET <base URL>/token`, as the app's
99
+ * `@neondatabase/auth` does, and reuses the token until shortly before it expires. The token is then
100
+ * verified like any bearer token, so the exchange never vouches for a session on its own.
101
+ */
102
+ export declare function neonAuthSessionExchange(config: {
103
+ readonly baseUrl: string;
104
+ }, options?: {
105
+ readonly fetch?: typeof fetch;
106
+ readonly now?: () => number;
107
+ }): SessionExchange;
82
108
  type Authenticated = {
83
109
  readonly status: "authenticated";
84
110
  readonly principal: Principal;
@@ -93,6 +119,9 @@ type Authenticated = {
93
119
  };
94
120
  /** Resolves a bearer credential to a principal. */
95
121
  export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier): Effect.Effect<Authenticated, never, OntologyBackend>;
96
- /** The {@link Authentication} middleware, verifying tokens with `verifyToken`. */
97
- export declare function authenticationLayer(verifyToken: TokenVerifier): Layer.Layer<Authentication, never, OntologyBackend>;
122
+ /**
123
+ * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
124
+ * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
125
+ */
126
+ export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange): Layer.Layer<Authentication, never, OntologyBackend>;
98
127
  export {};