@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.
- 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/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/config.d.ts +3 -0
- package/dist/service/config.js +18 -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 +18 -3
- 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
|
@@ -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")
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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>`. */
|
package/dist/service/config.js
CHANGED
|
@@ -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
|
};
|
package/dist/service/handlers.js
CHANGED
|
@@ -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<{
|
package/dist/service/schemas.js
CHANGED
|
@@ -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
|
-
}, {
|
|
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) }, {
|
|
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." }),
|