@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.
- package/README.md +7 -1
- package/dist/analyze.js +16 -1
- package/dist/blob.d.ts +39 -0
- package/dist/blob.js +49 -0
- package/dist/entity.d.ts +5 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +23 -7
- package/dist/manifest.js +9 -1
- package/dist/migrate/model.d.ts +7 -2
- package/dist/migrate/plan.js +26 -2
- package/dist/migrate/storage.d.ts +2 -0
- package/dist/migrate/storage.js +13 -0
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -0
- package/dist/property.d.ts +10 -1
- package/dist/property.js +12 -1
- package/dist/query.js +6 -1
- package/dist/runtime/generated-actions.js +7 -3
- package/dist/runtime/history.d.ts +3 -0
- package/dist/runtime/pipeline.d.ts +5 -1
- package/dist/runtime/pipeline.js +87 -3
- package/dist/runtime/records.js +9 -0
- package/dist/runtime/service-binding.d.ts +2 -1
- package/dist/runtime/service-binding.js +13 -2
- package/dist/runtime/writes.js +17 -3
- package/dist/service/api.js +15 -0
- package/dist/service/authentication.d.ts +33 -4
- package/dist/service/authentication.js +142 -6
- package/dist/service/config.d.ts +3 -0
- package/dist/service/config.js +18 -0
- package/dist/service/docs.d.ts +9 -0
- package/dist/service/docs.js +197 -0
- package/dist/service/errors.d.ts +5 -0
- package/dist/service/errors.js +14 -0
- package/dist/service/handlers.js +40 -1
- package/dist/service/runtime-contract.d.ts +9 -0
- package/dist/service/schemas.d.ts +10 -2
- package/dist/service/schemas.js +18 -2
- package/dist/service/service.js +22 -5
- package/dist/storage/api.d.ts +4 -0
- package/dist/storage/api.js +39 -0
- package/dist/storage/authorization.d.ts +8 -0
- package/dist/storage/authorization.js +30 -0
- package/dist/storage/maintenance-auth.d.ts +20 -0
- package/dist/storage/maintenance-auth.js +24 -0
- package/dist/storage/maintenance.d.ts +7 -0
- package/dist/storage/maintenance.js +50 -0
- package/dist/storage/neon.d.ts +3 -0
- package/dist/storage/neon.js +212 -0
- package/dist/storage/operator.d.ts +10 -0
- package/dist/storage/operator.js +30 -0
- package/dist/storage/provider.d.ts +80 -0
- package/dist/storage/provider.js +20 -0
- package/dist/storage/sessions.d.ts +87 -0
- package/dist/storage/sessions.js +341 -0
- package/dist/storage/tables.d.ts +2 -0
- package/dist/storage/tables.js +55 -0
- package/dist/storage/writes.d.ts +7 -0
- package/dist/storage/writes.js +88 -0
- 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
|
-
|
|
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.
|
|
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
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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,
|
package/dist/runtime/records.js
CHANGED
|
@@ -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
|
};
|
package/dist/runtime/writes.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 };
|
package/dist/service/api.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
97
|
-
|
|
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 {};
|