@segmentstream-cloud/ontology 0.9.0 → 0.11.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/action.d.ts +19 -0
- package/dist/action.js +1 -0
- package/dist/analyze.d.ts +3 -1
- package/dist/analyze.js +97 -2
- package/dist/bin/migrate.js +3 -2
- package/dist/blob.d.ts +39 -0
- package/dist/blob.js +49 -0
- package/dist/diagnostics.d.ts +3 -3
- package/dist/diagnostics.js +1 -0
- package/dist/entity.d.ts +5 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +6 -1
- package/dist/manifest.d.ts +71 -7
- package/dist/manifest.js +18 -1
- package/dist/migrate/apply.d.ts +7 -0
- package/dist/migrate/apply.js +18 -1
- package/dist/migrate/index.d.ts +1 -1
- package/dist/migrate/index.js +1 -1
- package/dist/migrate/model.d.ts +15 -2
- package/dist/migrate/plan.d.ts +6 -0
- package/dist/migrate/plan.js +35 -3
- package/dist/migrate/storage.d.ts +14 -0
- package/dist/migrate/storage.js +95 -0
- package/dist/names.d.ts +9 -1
- package/dist/names.js +11 -0
- package/dist/nitro.d.ts +13 -0
- package/dist/nitro.js +18 -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.d.ts +3 -0
- package/dist/runtime/generated-actions.js +10 -5
- package/dist/runtime/history.d.ts +3 -0
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +165 -0
- package/dist/runtime/pipeline.d.ts +5 -1
- package/dist/runtime/pipeline.js +87 -3
- package/dist/runtime/principals.d.ts +5 -0
- package/dist/runtime/principals.js +15 -0
- package/dist/runtime/records.js +9 -0
- package/dist/runtime/service-binding.d.ts +2 -1
- package/dist/runtime/service-binding.js +15 -2
- package/dist/runtime/writes.js +17 -3
- package/dist/service/api.js +19 -10
- package/dist/service/authentication.d.ts +27 -1
- package/dist/service/authentication.js +48 -26
- package/dist/service/config.d.ts +8 -0
- package/dist/service/config.js +18 -0
- package/dist/service/descriptions.d.ts +8 -0
- package/dist/service/descriptions.js +15 -0
- package/dist/service/errors.d.ts +5 -1
- package/dist/service/errors.js +6 -2
- package/dist/service/handlers.d.ts +1 -4
- package/dist/service/handlers.js +59 -203
- package/dist/service/mcp/server.d.ts +24 -0
- package/dist/service/mcp/server.js +370 -0
- package/dist/service/mcp/tools.d.ts +47 -0
- package/dist/service/mcp/tools.js +218 -0
- package/dist/service/oauth/protocol.d.ts +52 -0
- package/dist/service/oauth/protocol.js +110 -0
- package/dist/service/oauth/server.d.ts +27 -0
- package/dist/service/oauth/server.js +576 -0
- package/dist/service/operations.d.ts +114 -0
- package/dist/service/operations.js +218 -0
- package/dist/service/runtime-contract.d.ts +98 -0
- package/dist/service/schemas.d.ts +18 -2
- package/dist/service/schemas.js +18 -2
- package/dist/service/service.d.ts +3 -3
- package/dist/service/service.js +27 -7
- 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 +8 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { SqlClient } from "effect/unstable/sql";
|
|
3
|
+
import { ActionRejected, Conflict, NotFound } from "../errors.js";
|
|
4
|
+
import type { Ontology } from "../ontology.js";
|
|
5
|
+
import type { Principal } from "../runtime.js";
|
|
6
|
+
import type { BlobProvider } from "./provider.js";
|
|
7
|
+
export declare const BeginUpload: Schema.encodeKeys<Schema.Struct<{
|
|
8
|
+
readonly entity: Schema.String;
|
|
9
|
+
readonly property: Schema.String;
|
|
10
|
+
readonly action: Schema.String;
|
|
11
|
+
readonly recordId: Schema.optionalKey<Schema.String>;
|
|
12
|
+
readonly createIntent: Schema.optionalKey<Schema.String>;
|
|
13
|
+
readonly idempotencyKey: Schema.optionalKey<Schema.String>;
|
|
14
|
+
readonly filename: Schema.String;
|
|
15
|
+
readonly contentType: Schema.String;
|
|
16
|
+
readonly size: Schema.Int;
|
|
17
|
+
}>, {
|
|
18
|
+
readonly recordId: "record_id";
|
|
19
|
+
readonly createIntent: "create_intent";
|
|
20
|
+
readonly idempotencyKey: "idempotency_key";
|
|
21
|
+
readonly contentType: "content_type";
|
|
22
|
+
}>;
|
|
23
|
+
export type BeginUpload = typeof BeginUpload.Type;
|
|
24
|
+
export declare const UploadView: Schema.encodeKeys<Schema.Struct<{
|
|
25
|
+
readonly id: Schema.String;
|
|
26
|
+
readonly state: Schema.String;
|
|
27
|
+
readonly expiresAt: Schema.String;
|
|
28
|
+
readonly ticket: Schema.optionalKey<Schema.decodeTo<Schema.Struct<{
|
|
29
|
+
readonly uploadId: Schema.String;
|
|
30
|
+
}>, Schema.Struct<{
|
|
31
|
+
readonly upload_id: Schema.String;
|
|
32
|
+
}>, never, never>>;
|
|
33
|
+
}>, {
|
|
34
|
+
readonly expiresAt: "expires_at";
|
|
35
|
+
}>;
|
|
36
|
+
export declare const TransferGrant: Schema.encodeKeys<Schema.Struct<{
|
|
37
|
+
readonly url: Schema.String;
|
|
38
|
+
readonly method: Schema.Literals<readonly ["GET", "PUT"]>;
|
|
39
|
+
readonly headers: Schema.$Record<Schema.String, Schema.String>;
|
|
40
|
+
readonly expiresAt: Schema.String;
|
|
41
|
+
}>, {
|
|
42
|
+
readonly expiresAt: "expires_at";
|
|
43
|
+
}>;
|
|
44
|
+
export declare const DownloadRequest: Schema.encodeKeys<Schema.Struct<{
|
|
45
|
+
readonly revisionId: Schema.optionalKey<Schema.String>;
|
|
46
|
+
readonly inline: Schema.optionalKey<Schema.Boolean>;
|
|
47
|
+
}>, {
|
|
48
|
+
readonly revisionId: "revision_id";
|
|
49
|
+
}>;
|
|
50
|
+
/** Persisted phases surround every provider call; no external I/O holds a database transaction. */
|
|
51
|
+
export declare function blobRuntime(ontology: Ontology, sql: SqlClient.SqlClient, provider: BlobProvider["Service"]): {
|
|
52
|
+
begin: (input: BeginUpload, principal: Principal) => Effect.Effect<{
|
|
53
|
+
ticket?: {
|
|
54
|
+
uploadId: string;
|
|
55
|
+
} | undefined;
|
|
56
|
+
id: string;
|
|
57
|
+
state: string;
|
|
58
|
+
expiresAt: string;
|
|
59
|
+
}, ActionRejected | import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
60
|
+
status: (id: string, principal: Principal) => Effect.Effect<{
|
|
61
|
+
ticket?: {
|
|
62
|
+
uploadId: string;
|
|
63
|
+
} | undefined;
|
|
64
|
+
id: string;
|
|
65
|
+
state: string;
|
|
66
|
+
expiresAt: string;
|
|
67
|
+
}, import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
68
|
+
part: (id: string, principal: Principal) => Effect.Effect<import("./provider.js").TransferGrant, import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
69
|
+
complete: (id: string, principal: Principal) => Effect.Effect<{
|
|
70
|
+
ticket?: {
|
|
71
|
+
uploadId: string;
|
|
72
|
+
} | undefined;
|
|
73
|
+
id: string;
|
|
74
|
+
state: string;
|
|
75
|
+
expiresAt: string;
|
|
76
|
+
}, ActionRejected | import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
77
|
+
cancel: (id: string, principal: Principal) => Effect.Effect<{
|
|
78
|
+
ticket?: {
|
|
79
|
+
uploadId: string;
|
|
80
|
+
} | undefined;
|
|
81
|
+
id: string;
|
|
82
|
+
state: string;
|
|
83
|
+
expiresAt: string;
|
|
84
|
+
}, import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
85
|
+
download: (entityName: string, recordId: string, propertyName: string, input: typeof DownloadRequest.Type, principal: Principal) => Effect.Effect<import("./provider.js").TransferGrant, import("../errors.js").PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
86
|
+
};
|
|
87
|
+
export type BlobRuntime = ReturnType<typeof blobRuntime>;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import { uploadInput, BLOB_UPLOAD_ANNOTATION, MAX_BLOB_BYTES } from "../blob.js";
|
|
4
|
+
import { wireStruct } from "../wire.js";
|
|
5
|
+
import { ActionRejected, Conflict, NotFound } from "../errors.js";
|
|
6
|
+
import { authorizeBlobOperation } from "./authorization.js";
|
|
7
|
+
import { resolveAction } from "../runtime/pipeline.js";
|
|
8
|
+
import { makeOntologyRuntime } from "../runtime/sql-runtime.js";
|
|
9
|
+
const Id = Schema.String.check(Schema.isUUID());
|
|
10
|
+
export const BeginUpload = wireStruct({
|
|
11
|
+
entity: Schema.String,
|
|
12
|
+
property: Schema.String,
|
|
13
|
+
action: Schema.String,
|
|
14
|
+
recordId: Schema.optionalKey(Id),
|
|
15
|
+
createIntent: Schema.optionalKey(Id),
|
|
16
|
+
idempotencyKey: Schema.optionalKey(Id),
|
|
17
|
+
filename: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(255)),
|
|
18
|
+
contentType: Schema.String,
|
|
19
|
+
size: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_BLOB_BYTES)),
|
|
20
|
+
}, {
|
|
21
|
+
recordId: "record_id",
|
|
22
|
+
createIntent: "create_intent",
|
|
23
|
+
idempotencyKey: "idempotency_key",
|
|
24
|
+
contentType: "content_type",
|
|
25
|
+
});
|
|
26
|
+
export const UploadView = wireStruct({
|
|
27
|
+
id: Id,
|
|
28
|
+
state: Schema.String,
|
|
29
|
+
expiresAt: Schema.String,
|
|
30
|
+
ticket: Schema.optionalKey(uploadInput()),
|
|
31
|
+
}, { expiresAt: "expires_at" });
|
|
32
|
+
export const TransferGrant = wireStruct({
|
|
33
|
+
url: Schema.String,
|
|
34
|
+
method: Schema.Literals(["GET", "PUT"]),
|
|
35
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
36
|
+
expiresAt: Schema.String,
|
|
37
|
+
}, { expiresAt: "expires_at" });
|
|
38
|
+
export const DownloadRequest = wireStruct({
|
|
39
|
+
revisionId: Schema.optionalKey(Id),
|
|
40
|
+
inline: Schema.optionalKey(Schema.Boolean),
|
|
41
|
+
}, { revisionId: "revision_id" });
|
|
42
|
+
function allowsUpload(schema, entity, property) {
|
|
43
|
+
if (typeof schema !== "object" || schema === null) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const object = schema;
|
|
47
|
+
const binding = object[BLOB_UPLOAD_ANNOTATION];
|
|
48
|
+
return ((binding?.entity === entity && binding.property === property) ||
|
|
49
|
+
Object.values(object).some((child) => allowsUpload(child, entity, property)));
|
|
50
|
+
}
|
|
51
|
+
function sniff(bytes, contentType) {
|
|
52
|
+
const starts = (magic) => magic.every((byte, index) => bytes[index] === byte);
|
|
53
|
+
switch (contentType) {
|
|
54
|
+
case "application/pdf":
|
|
55
|
+
return starts([37, 80, 68, 70, 45]);
|
|
56
|
+
case "image/png":
|
|
57
|
+
return starts([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
58
|
+
case "image/jpeg":
|
|
59
|
+
return starts([255, 216, 255]);
|
|
60
|
+
case "text/plain":
|
|
61
|
+
try {
|
|
62
|
+
// Streaming decoding allows the prefix to end in the middle of a UTF-8 character.
|
|
63
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true });
|
|
64
|
+
return bytes.every((byte) => byte >= 32 || [9, 10, 13].includes(byte));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Persisted phases surround every provider call; no external I/O holds a database transaction. */
|
|
74
|
+
export function blobRuntime(ontology, sql, provider) {
|
|
75
|
+
const binding = provider.binding;
|
|
76
|
+
const conflict = () => new Conflict({ entity: "upload", message: "The upload is unavailable or busy." });
|
|
77
|
+
const view = (row) => ({
|
|
78
|
+
id: row.id,
|
|
79
|
+
state: row.state,
|
|
80
|
+
expiresAt: row.expires_at,
|
|
81
|
+
...(row.state === "ready" && !row.expired ? { ticket: { uploadId: row.id } } : {}),
|
|
82
|
+
});
|
|
83
|
+
const load = (id, principal) => Effect.gen(function* () {
|
|
84
|
+
const rows = yield* sql.unsafe(`SELECT u.*, b.key,
|
|
85
|
+
to_char(u.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS expires_at,
|
|
86
|
+
u.expires_at <= now() AS expired,
|
|
87
|
+
u.claimed_at < now() - interval '5 minutes' AS claim_stale
|
|
88
|
+
FROM "_blob_uploads" u JOIN "_blobs" b ON b.id = u.blob_id
|
|
89
|
+
WHERE u.id = $1::uuid AND u.principal_id = $2::uuid AND u.branch_id = $3
|
|
90
|
+
FOR UPDATE OF u, b`, [id, principal.id, binding.branchId]);
|
|
91
|
+
const row = rows[0];
|
|
92
|
+
if (!row) {
|
|
93
|
+
return yield* new NotFound({ entity: "upload", id, message: "Upload not found." });
|
|
94
|
+
}
|
|
95
|
+
const [entity, action] = row.action.split(".");
|
|
96
|
+
const found = resolveAction(ontology, entity, action);
|
|
97
|
+
if (!found || row.manifest_hash !== ontology.manifest.hash) {
|
|
98
|
+
return yield* conflict();
|
|
99
|
+
}
|
|
100
|
+
yield* authorizeBlobOperation(ontology, sql, principal.id, found.permissions);
|
|
101
|
+
return row;
|
|
102
|
+
});
|
|
103
|
+
const begin = (input, principal) => Effect.gen(function* () {
|
|
104
|
+
const entity = ontology.manifest.entities.find((item) => item.name === input.entity);
|
|
105
|
+
const property = entity?.properties.find((item) => item.name === input.property && item.type === "blob");
|
|
106
|
+
const found = resolveAction(ontology, input.entity, input.action);
|
|
107
|
+
if (!entity ||
|
|
108
|
+
entity.source ||
|
|
109
|
+
!property ||
|
|
110
|
+
!found ||
|
|
111
|
+
!property.accept?.includes(input.contentType) ||
|
|
112
|
+
input.size > (property.maxBytes ?? MAX_BLOB_BYTES) ||
|
|
113
|
+
Boolean(input.recordId) === Boolean(input.createIntent) ||
|
|
114
|
+
/[\x00-\x1f\x7f/\\]/.test(input.filename)) {
|
|
115
|
+
return yield* new ActionRejected({ message: "The file or upload target is invalid." });
|
|
116
|
+
}
|
|
117
|
+
const schema = Schema.toJsonSchemaDocument(found.action.input, {
|
|
118
|
+
includeAnnotationKey: (key) => key === BLOB_UPLOAD_ANNOTATION,
|
|
119
|
+
});
|
|
120
|
+
if (!allowsUpload(schema, input.entity, input.property)) {
|
|
121
|
+
return yield* new ActionRejected({
|
|
122
|
+
message: "This action does not accept an upload for that field.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// A caller-generated UUID is also the session identity, without changing existing tables.
|
|
126
|
+
const id = input.idempotencyKey ?? randomUUID();
|
|
127
|
+
const blobId = randomUUID();
|
|
128
|
+
const claim = randomUUID();
|
|
129
|
+
const key = `revisions/${blobId}`;
|
|
130
|
+
const reservation = yield* sql.withTransaction(Effect.gen(function* () {
|
|
131
|
+
yield* authorizeBlobOperation(ontology, sql, principal.id, found.permissions);
|
|
132
|
+
if (input.recordId) {
|
|
133
|
+
yield* makeOntologyRuntime(ontology, sql).get({
|
|
134
|
+
entity: input.entity,
|
|
135
|
+
id: input.recordId,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
yield* sql.unsafe(`SELECT pg_advisory_xact_lock(818723)`);
|
|
139
|
+
const existing = yield* sql.unsafe(`SELECT id FROM "_blob_uploads" WHERE id = $1::uuid`, [
|
|
140
|
+
id,
|
|
141
|
+
]);
|
|
142
|
+
if (existing.length) {
|
|
143
|
+
const row = yield* load(id, principal);
|
|
144
|
+
if (row.expired ||
|
|
145
|
+
row.action !== `${input.entity}.${input.action}` ||
|
|
146
|
+
row.entity_id !== entity.id ||
|
|
147
|
+
row.property_id !== property.name ||
|
|
148
|
+
row.record_id !== (input.recordId ?? null) ||
|
|
149
|
+
row.create_intent !== (input.createIntent ?? null) ||
|
|
150
|
+
row.filename !== input.filename ||
|
|
151
|
+
row.content_type !== input.contentType ||
|
|
152
|
+
Number(row.size) !== input.size) {
|
|
153
|
+
return yield* conflict();
|
|
154
|
+
}
|
|
155
|
+
if (["uploading", "completing", "ready"].includes(row.state)) {
|
|
156
|
+
return { row, recover: false };
|
|
157
|
+
}
|
|
158
|
+
if (row.state !== "initializing" || (row.claim && !row.claim_stale)) {
|
|
159
|
+
return yield* conflict();
|
|
160
|
+
}
|
|
161
|
+
yield* sql.unsafe(`UPDATE "_blob_uploads" SET claim = $1::uuid, claimed_at = now() WHERE id = $2::uuid`, [claim, row.id]);
|
|
162
|
+
return { row, recover: true };
|
|
163
|
+
}
|
|
164
|
+
const [usage] = yield* sql.unsafe(`SELECT (SELECT coalesce(sum(size), 0)::text FROM "_blobs" WHERE state <> 'deleted') AS bytes,
|
|
165
|
+
(SELECT count(*)::text FROM "_blob_uploads" WHERE principal_id = $1::uuid
|
|
166
|
+
AND state IN ('initializing', 'uploading', 'completing', 'ready', 'deleting')) AS pending`, [principal.id]);
|
|
167
|
+
if (Number(usage.bytes) + input.size > binding.quotaBytes ||
|
|
168
|
+
Number(usage.pending) >= 8) {
|
|
169
|
+
return yield* new ActionRejected({
|
|
170
|
+
message: "File storage capacity or pending upload limit reached.",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
yield* sql.unsafe(`INSERT INTO "_blobs"
|
|
174
|
+
(id, branch_id, key, filename, content_type, size, state)
|
|
175
|
+
VALUES ($1::uuid, $2, $3, $4, $5, $6::bigint, 'ready')`, [blobId, binding.branchId, key, input.filename, input.contentType, input.size]);
|
|
176
|
+
yield* sql.unsafe(`INSERT INTO "_blob_uploads"
|
|
177
|
+
(id, blob_id, principal_id, branch_id, manifest_hash, action, entity_id, property_id,
|
|
178
|
+
record_id, create_intent, filename, content_type, size, state, claim, claimed_at)
|
|
179
|
+
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9::uuid, $10::uuid, $11, $12,
|
|
180
|
+
$13::bigint, 'initializing', $14::uuid, now())`, [
|
|
181
|
+
id,
|
|
182
|
+
blobId,
|
|
183
|
+
principal.id,
|
|
184
|
+
binding.branchId,
|
|
185
|
+
ontology.manifest.hash,
|
|
186
|
+
`${input.entity}.${input.action}`,
|
|
187
|
+
entity.id,
|
|
188
|
+
property.name,
|
|
189
|
+
input.recordId ?? null,
|
|
190
|
+
input.createIntent ?? null,
|
|
191
|
+
input.filename,
|
|
192
|
+
input.contentType,
|
|
193
|
+
input.size,
|
|
194
|
+
claim,
|
|
195
|
+
]);
|
|
196
|
+
return { row: yield* load(id, principal), recover: false };
|
|
197
|
+
}));
|
|
198
|
+
const { row } = reservation;
|
|
199
|
+
if (row.state !== "initializing") {
|
|
200
|
+
return view(row);
|
|
201
|
+
}
|
|
202
|
+
const handles = reservation.recover ? yield* provider.pending(row.key) : [];
|
|
203
|
+
const uploadId = handles[0] ??
|
|
204
|
+
(yield* provider.initiate({
|
|
205
|
+
key: row.key,
|
|
206
|
+
filename: row.filename,
|
|
207
|
+
contentType: row.content_type,
|
|
208
|
+
sessionId: row.id,
|
|
209
|
+
}));
|
|
210
|
+
const persisted = yield* sql.withTransaction(sql.unsafe(`UPDATE "_blob_uploads" SET
|
|
211
|
+
state = 'uploading', provider_upload_id = $1, claim = NULL, claimed_at = NULL
|
|
212
|
+
WHERE id = $2::uuid AND state = 'initializing' AND claim = $3::uuid RETURNING id`, [uploadId, row.id, claim]));
|
|
213
|
+
if (!persisted.length) {
|
|
214
|
+
const [latest] = yield* sql.withTransaction(sql.unsafe(`SELECT state, provider_upload_id FROM "_blob_uploads" WHERE id = $1::uuid`, [row.id]));
|
|
215
|
+
// A replacement claim may have adopted this handle after our response was delayed.
|
|
216
|
+
if (latest?.state !== "initializing" && latest?.provider_upload_id !== uploadId) {
|
|
217
|
+
yield* provider.abort({ key: row.key, uploadId });
|
|
218
|
+
}
|
|
219
|
+
return yield* conflict();
|
|
220
|
+
}
|
|
221
|
+
return yield* sql.withTransaction(load(row.id, principal).pipe(Effect.map(view)));
|
|
222
|
+
});
|
|
223
|
+
const status = (id, principal) => sql.withTransaction(load(id, principal).pipe(Effect.map(view)));
|
|
224
|
+
const part = (id, principal) => Effect.gen(function* () {
|
|
225
|
+
const row = yield* sql.withTransaction(load(id, principal).pipe(Effect.flatMap((row) => row.state === "uploading" && !row.expired && row.provider_upload_id
|
|
226
|
+
? Effect.succeed(row)
|
|
227
|
+
: Effect.fail(conflict()))));
|
|
228
|
+
return yield* provider.signPart({
|
|
229
|
+
key: row.key,
|
|
230
|
+
uploadId: row.provider_upload_id,
|
|
231
|
+
partNumber: 1,
|
|
232
|
+
size: Number(row.size),
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const complete = (id, principal) => Effect.gen(function* () {
|
|
236
|
+
const claim = randomUUID();
|
|
237
|
+
const row = yield* sql.withTransaction(Effect.gen(function* () {
|
|
238
|
+
const row = yield* load(id, principal);
|
|
239
|
+
if (row.state === "ready" && !row.expired) {
|
|
240
|
+
return row;
|
|
241
|
+
}
|
|
242
|
+
if (row.expired ||
|
|
243
|
+
!row.provider_upload_id ||
|
|
244
|
+
!(row.state === "uploading" || (row.state === "completing" && row.claim_stale))) {
|
|
245
|
+
return yield* conflict();
|
|
246
|
+
}
|
|
247
|
+
yield* sql.unsafe(`UPDATE "_blob_uploads" SET state = 'completing', claim = $1::uuid, claimed_at = now()
|
|
248
|
+
WHERE id = $2::uuid`, [claim, id]);
|
|
249
|
+
return row;
|
|
250
|
+
}));
|
|
251
|
+
if (row.state === "ready") {
|
|
252
|
+
return view(row);
|
|
253
|
+
}
|
|
254
|
+
let stored = yield* provider.inspect(row.key);
|
|
255
|
+
if (!stored) {
|
|
256
|
+
const parts = yield* provider.parts({ key: row.key, uploadId: row.provider_upload_id });
|
|
257
|
+
if (parts.length !== 1 || parts[0]?.number !== 1 || parts[0].size !== Number(row.size)) {
|
|
258
|
+
return yield* new ActionRejected({
|
|
259
|
+
message: "The uploaded file does not match its declared size.",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
yield* provider.complete({ key: row.key, uploadId: row.provider_upload_id, parts });
|
|
263
|
+
stored = yield* provider.inspect(row.key);
|
|
264
|
+
}
|
|
265
|
+
const prefix = yield* provider.readPrefix(row.key);
|
|
266
|
+
if (!stored ||
|
|
267
|
+
stored.size !== Number(row.size) ||
|
|
268
|
+
stored.sessionId !== id ||
|
|
269
|
+
stored.contentType !== row.content_type ||
|
|
270
|
+
!sniff(prefix, row.content_type)) {
|
|
271
|
+
yield* sql.withTransaction(sql.unsafe(`UPDATE "_blob_uploads" SET state = 'canceled', expires_at = now(),
|
|
272
|
+
claim = NULL WHERE id = $1::uuid AND claim = $2::uuid`, [id, claim]));
|
|
273
|
+
return yield* new ActionRejected({
|
|
274
|
+
message: "The file's contents do not match its declared type or size.",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
278
|
+
const current = yield* load(id, principal);
|
|
279
|
+
if (current.state !== "completing" || current.claim !== claim || current.expired) {
|
|
280
|
+
return yield* conflict();
|
|
281
|
+
}
|
|
282
|
+
yield* sql.unsafe(`UPDATE "_blob_uploads" SET state = 'ready', expires_at = now() + interval '24 hours',
|
|
283
|
+
claim = NULL, claimed_at = NULL WHERE id = $1::uuid`, [id]);
|
|
284
|
+
return view(yield* load(id, principal));
|
|
285
|
+
}));
|
|
286
|
+
});
|
|
287
|
+
const cancel = (id, principal) => sql.withTransaction(Effect.gen(function* () {
|
|
288
|
+
const row = yield* load(id, principal);
|
|
289
|
+
if (row.state === "consumed" ||
|
|
290
|
+
row.state === "completing" ||
|
|
291
|
+
row.state === "initializing") {
|
|
292
|
+
return yield* conflict();
|
|
293
|
+
}
|
|
294
|
+
if (["deleted", "deleting", "canceled"].includes(row.state)) {
|
|
295
|
+
return view(row);
|
|
296
|
+
}
|
|
297
|
+
yield* sql.unsafe(`UPDATE "_blob_uploads" SET state = 'canceled', expires_at = now(), claim = NULL
|
|
298
|
+
WHERE id = $1::uuid`, [id]);
|
|
299
|
+
return view(yield* load(id, principal));
|
|
300
|
+
}));
|
|
301
|
+
const download = (entityName, recordId, propertyName, input, principal) => Effect.gen(function* () {
|
|
302
|
+
const entity = ontology.manifest.entities.find((item) => item.name === entityName);
|
|
303
|
+
const property = entity?.properties.find((item) => item.name === propertyName && item.type === "blob");
|
|
304
|
+
if (!entity || !property) {
|
|
305
|
+
return yield* new NotFound({
|
|
306
|
+
entity: entityName,
|
|
307
|
+
id: recordId,
|
|
308
|
+
message: "File not found.",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const blob = yield* sql.withTransaction(Effect.gen(function* () {
|
|
312
|
+
yield* authorizeBlobOperation(ontology, sql, principal.id, [`${entityName}:read`]);
|
|
313
|
+
let revisionId = input.revisionId;
|
|
314
|
+
if (!revisionId) {
|
|
315
|
+
const record = yield* makeOntologyRuntime(ontology, sql).get({
|
|
316
|
+
entity: entityName,
|
|
317
|
+
id: recordId,
|
|
318
|
+
});
|
|
319
|
+
revisionId = record[propertyName]?.id;
|
|
320
|
+
}
|
|
321
|
+
const [blob] = yield* sql.unsafe(`SELECT b.key, b.filename, b.content_type FROM "_blobs" b JOIN "_blob_references" r ON r.blob_id = b.id
|
|
322
|
+
WHERE b.id = $1::uuid AND b.state = 'retained' AND r.entity_id = $2
|
|
323
|
+
AND r.property_id = $3 AND r.record_id = $4::uuid FOR SHARE OF b`, [revisionId ?? null, entity.id, property.name, recordId]);
|
|
324
|
+
if (!blob) {
|
|
325
|
+
return yield* new NotFound({
|
|
326
|
+
entity: entityName,
|
|
327
|
+
id: recordId,
|
|
328
|
+
message: "File not found.",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return blob;
|
|
332
|
+
}));
|
|
333
|
+
return yield* provider.download({
|
|
334
|
+
key: blob.key,
|
|
335
|
+
filename: blob.filename,
|
|
336
|
+
contentType: blob.content_type,
|
|
337
|
+
inline: input.inline === true && blob.content_type.startsWith("image/"),
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
return { begin, status, part, complete, cancel, download };
|
|
341
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Storage metadata lives in the ontology database and is migrated by its SDK. */
|
|
2
|
+
export const BLOB_TABLES_DDL = [
|
|
3
|
+
`CREATE TABLE IF NOT EXISTS "_blobs" (
|
|
4
|
+
"id" uuid PRIMARY KEY,
|
|
5
|
+
"branch_id" text NOT NULL,
|
|
6
|
+
"key" text NOT NULL UNIQUE,
|
|
7
|
+
"filename" text NOT NULL,
|
|
8
|
+
"content_type" text NOT NULL,
|
|
9
|
+
"size" bigint NOT NULL CHECK ("size" > 0),
|
|
10
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
11
|
+
"state" text NOT NULL CHECK ("state" IN ('ready', 'retained', 'deleting', 'deleted')),
|
|
12
|
+
"entity_id" text,
|
|
13
|
+
"property_id" text,
|
|
14
|
+
"record_id" uuid
|
|
15
|
+
)`,
|
|
16
|
+
`CREATE TABLE IF NOT EXISTS "_blob_uploads" (
|
|
17
|
+
"id" uuid PRIMARY KEY,
|
|
18
|
+
"blob_id" uuid NOT NULL UNIQUE REFERENCES "_blobs" ("id"),
|
|
19
|
+
"principal_id" uuid NOT NULL REFERENCES "user" ("id"),
|
|
20
|
+
"branch_id" text NOT NULL,
|
|
21
|
+
"manifest_hash" text NOT NULL,
|
|
22
|
+
"action" text NOT NULL,
|
|
23
|
+
"entity_id" text NOT NULL,
|
|
24
|
+
"property_id" text NOT NULL,
|
|
25
|
+
"record_id" uuid,
|
|
26
|
+
"create_intent" uuid,
|
|
27
|
+
"filename" text NOT NULL,
|
|
28
|
+
"content_type" text NOT NULL,
|
|
29
|
+
"size" bigint NOT NULL CHECK ("size" > 0),
|
|
30
|
+
"provider_upload_id" text,
|
|
31
|
+
"state" text NOT NULL CHECK ("state" IN
|
|
32
|
+
('initializing', 'uploading', 'completing', 'ready', 'consumed', 'canceled', 'deleting', 'deleted')),
|
|
33
|
+
"claim" uuid,
|
|
34
|
+
"claimed_at" timestamptz,
|
|
35
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
"expires_at" timestamptz NOT NULL DEFAULT now() + interval '1 hour',
|
|
37
|
+
CHECK (("record_id" IS NULL) <> ("create_intent" IS NULL))
|
|
38
|
+
)`,
|
|
39
|
+
`CREATE INDEX IF NOT EXISTS "_blob_uploads_expiry_idx" ON "_blob_uploads" ("state", "expires_at")`,
|
|
40
|
+
`CREATE TABLE IF NOT EXISTS "_blob_references" (
|
|
41
|
+
"blob_id" uuid NOT NULL REFERENCES "_blobs" ("id"),
|
|
42
|
+
"entity_id" text NOT NULL,
|
|
43
|
+
"property_id" text NOT NULL,
|
|
44
|
+
"record_id" uuid NOT NULL,
|
|
45
|
+
"action_id" uuid NOT NULL,
|
|
46
|
+
PRIMARY KEY ("blob_id", "entity_id", "property_id", "record_id")
|
|
47
|
+
)`,
|
|
48
|
+
`CREATE TABLE IF NOT EXISTS "_blob_action_results" (
|
|
49
|
+
"principal_id" uuid NOT NULL REFERENCES "user" ("id"),
|
|
50
|
+
"key" text NOT NULL,
|
|
51
|
+
"fingerprint" text NOT NULL,
|
|
52
|
+
"outcome" jsonb,
|
|
53
|
+
PRIMARY KEY ("principal_id", "key")
|
|
54
|
+
)`,
|
|
55
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Conflict } from "../errors.js";
|
|
3
|
+
import type { StoredValues } from "../runtime.js";
|
|
4
|
+
import type { EntityModel } from "../runtime/model.js";
|
|
5
|
+
import type { WriteContext } from "../runtime/writes.js";
|
|
6
|
+
/** Resolves tickets in the action's transaction. There is no provider I/O here. */
|
|
7
|
+
export declare function resolveBlobWrites(context: WriteContext, entity: EntityModel, recordId: string, values: StoredValues, creating: boolean): Effect.Effect<Record<string, unknown>, Conflict | import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { authorizeBlobOperation } from "./authorization.js";
|
|
3
|
+
import { generatedActionPermissions } from "../permissions.js";
|
|
4
|
+
import { Conflict } from "../errors.js";
|
|
5
|
+
import { isRecordId } from "../runtime/records.js";
|
|
6
|
+
import { Params } from "../runtime/statement.js";
|
|
7
|
+
/** Resolves tickets in the action's transaction. There is no provider I/O here. */
|
|
8
|
+
export function resolveBlobWrites(context, entity, recordId, values, creating) {
|
|
9
|
+
return Effect.gen(function* () {
|
|
10
|
+
const resolved = { ...values };
|
|
11
|
+
for (const property of entity.manifest.properties.filter((field) => field.type === "blob")) {
|
|
12
|
+
const value = values[property.name];
|
|
13
|
+
if (value === undefined || value === null) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const reject = () => new Conflict({
|
|
17
|
+
entity: entity.name,
|
|
18
|
+
property: property.name,
|
|
19
|
+
message: "This upload cannot be attached to this record.",
|
|
20
|
+
});
|
|
21
|
+
const uploadId = typeof value === "object" && "uploadId" in value ? value.uploadId : undefined;
|
|
22
|
+
if (typeof uploadId !== "string" ||
|
|
23
|
+
!isRecordId(uploadId) ||
|
|
24
|
+
!context.scope.branchId ||
|
|
25
|
+
!context.scope.principalId ||
|
|
26
|
+
!context.scope.blobActionKey) {
|
|
27
|
+
return yield* reject();
|
|
28
|
+
}
|
|
29
|
+
const [actionEntity, actionName] = context.scope.action.split(".");
|
|
30
|
+
const authored = context.model.ontology.entities
|
|
31
|
+
.find((item) => item.name === actionEntity)
|
|
32
|
+
?.actions.find((item) => item.name === actionName);
|
|
33
|
+
yield* authorizeBlobOperation(context.model.ontology, {
|
|
34
|
+
unsafe: (statement, args) => {
|
|
35
|
+
const params = new Params();
|
|
36
|
+
// The authorization statements use positional parameters and no dynamic identifiers.
|
|
37
|
+
for (const value of args) {
|
|
38
|
+
params.add(value, "text");
|
|
39
|
+
}
|
|
40
|
+
return context.run(statement, params);
|
|
41
|
+
},
|
|
42
|
+
}, context.scope.principalId, authored ? [authored.permission] : generatedActionPermissions(actionEntity, actionName)).pipe(Effect.catchTag("PermissionDenied", () => Effect.fail(reject())));
|
|
43
|
+
const params = new Params();
|
|
44
|
+
const rows = yield* context.run(`SELECT u.*, b.id::text AS blob_id, b.filename, b.content_type, b.size::text AS size,
|
|
45
|
+
to_char(b.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_at
|
|
46
|
+
FROM "_blob_uploads" u JOIN "_blobs" b ON b.id = u.blob_id
|
|
47
|
+
JOIN "user" p ON p.id = u.principal_id AND p.active = true
|
|
48
|
+
WHERE u.id = ${params.add(uploadId, "uuid")}
|
|
49
|
+
AND u.principal_id = ${params.add(context.scope.principalId, "uuid")}
|
|
50
|
+
AND u.branch_id = ${params.add(context.scope.branchId, "text")}
|
|
51
|
+
AND u.manifest_hash = ${params.add(context.model.ontology.manifest.hash, "text")}
|
|
52
|
+
AND u.action = ${params.add(context.scope.action, "text")}
|
|
53
|
+
AND u.entity_id = ${params.add(entity.manifest.id, "text")}
|
|
54
|
+
AND u.property_id = ${params.add(property.name, "text")}
|
|
55
|
+
AND u.state = 'ready' AND u.expires_at > now() AND b.state = 'ready'
|
|
56
|
+
FOR UPDATE OF u, b FOR SHARE OF p`, params);
|
|
57
|
+
const row = rows[0];
|
|
58
|
+
if (!row ||
|
|
59
|
+
(creating
|
|
60
|
+
? row.create_intent !== context.scope.createIntent
|
|
61
|
+
: row.record_id !== recordId.toLowerCase())) {
|
|
62
|
+
return yield* reject();
|
|
63
|
+
}
|
|
64
|
+
const consume = new Params();
|
|
65
|
+
yield* context.run(`UPDATE "_blob_uploads" SET state = 'consumed'
|
|
66
|
+
WHERE id = ${consume.add(uploadId, "uuid")}`, consume);
|
|
67
|
+
const retain = new Params();
|
|
68
|
+
yield* context.run(`UPDATE "_blobs" SET state = 'retained',
|
|
69
|
+
entity_id = ${retain.add(entity.manifest.id, "text")},
|
|
70
|
+
property_id = ${retain.add(property.name, "text")}, record_id = ${retain.add(recordId, "uuid")}
|
|
71
|
+
WHERE id = ${retain.add(row.blob_id, "uuid")}`, retain);
|
|
72
|
+
const reference = new Params();
|
|
73
|
+
yield* context.run(`INSERT INTO "_blob_references"
|
|
74
|
+
(blob_id, entity_id, property_id, record_id, action_id) VALUES
|
|
75
|
+
(${reference.add(row.blob_id, "uuid")}, ${reference.add(entity.manifest.id, "text")},
|
|
76
|
+
${reference.add(property.name, "text")}, ${reference.add(recordId, "uuid")},
|
|
77
|
+
${reference.add(context.scope.actionId, "uuid")})`, reference);
|
|
78
|
+
resolved[property.name] = {
|
|
79
|
+
id: row.blob_id,
|
|
80
|
+
filename: row.filename,
|
|
81
|
+
contentType: row.content_type,
|
|
82
|
+
size: Number(row.size),
|
|
83
|
+
createdAt: row.created_at,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return resolved;
|
|
87
|
+
});
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./nitro": {
|
|
25
|
+
"types": "./dist/nitro.d.ts",
|
|
26
|
+
"import": "./dist/nitro.js"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"bin": {
|
|
@@ -43,6 +47,8 @@
|
|
|
43
47
|
"prepack": "npm run build"
|
|
44
48
|
},
|
|
45
49
|
"dependencies": {
|
|
50
|
+
"@aws-sdk/client-s3": "^3.1148.0",
|
|
51
|
+
"@aws-sdk/s3-request-presigner": "^3.1148.0",
|
|
46
52
|
"@effect/sql-pg": "4.0.0-rc.113",
|
|
47
53
|
"jose": "^6.2.12",
|
|
48
54
|
"tsx": "4.21.0"
|
|
@@ -52,6 +58,7 @@
|
|
|
52
58
|
},
|
|
53
59
|
"devDependencies": {
|
|
54
60
|
"@electric-sql/pglite": "0.4.6",
|
|
61
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
55
62
|
"@types/node": "^24",
|
|
56
63
|
"effect": "4.0.0-rc.113",
|
|
57
64
|
"typescript": "^5.9",
|