@electric-ax/agents-server 0.4.20 → 0.5.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/entrypoint.js +107 -7
- package/dist/index.cjs +106 -6
- package/dist/index.d.cts +2484 -2431
- package/dist/index.d.ts +2483 -2432
- package/dist/index.js +107 -7
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/db/schema.ts +4 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +126 -0
- package/src/entity-registry.ts +14 -0
- package/src/routing/entities-router.ts +61 -2
- package/src/routing/entity-types-router.ts +56 -0
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import postgres from "postgres";
|
|
|
8
8
|
import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
9
9
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
10
10
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
11
|
-
import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
11
|
+
import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
12
12
|
import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
|
|
13
13
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
14
14
|
import pino from "pino";
|
|
@@ -50,6 +50,7 @@ const entityTypes = pgTable(`entity_types`, {
|
|
|
50
50
|
creationSchema: jsonb(`creation_schema`),
|
|
51
51
|
inboxSchemas: jsonb(`inbox_schemas`),
|
|
52
52
|
stateSchemas: jsonb(`state_schemas`),
|
|
53
|
+
externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
|
|
53
54
|
slashCommands: jsonb(`slash_commands`),
|
|
54
55
|
serveEndpoint: text(`serve_endpoint`),
|
|
55
56
|
defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
|
|
@@ -837,6 +838,7 @@ var PostgresRegistry = class {
|
|
|
837
838
|
creationSchema: et.creation_schema ?? null,
|
|
838
839
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
839
840
|
stateSchemas: et.state_schemas ?? null,
|
|
841
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
840
842
|
slashCommands: et.slash_commands ?? null,
|
|
841
843
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
842
844
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -850,6 +852,7 @@ var PostgresRegistry = class {
|
|
|
850
852
|
creationSchema: et.creation_schema ?? null,
|
|
851
853
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
852
854
|
stateSchemas: et.state_schemas ?? null,
|
|
855
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
853
856
|
slashCommands: et.slash_commands ?? null,
|
|
854
857
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
855
858
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -868,6 +871,7 @@ var PostgresRegistry = class {
|
|
|
868
871
|
creationSchema: et.creation_schema ?? null,
|
|
869
872
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
870
873
|
stateSchemas: et.state_schemas ?? null,
|
|
874
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
871
875
|
slashCommands: et.slash_commands ?? null,
|
|
872
876
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
873
877
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -895,6 +899,7 @@ var PostgresRegistry = class {
|
|
|
895
899
|
creationSchema: et.creation_schema ?? null,
|
|
896
900
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
897
901
|
stateSchemas: et.state_schemas ?? null,
|
|
902
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
898
903
|
slashCommands: et.slash_commands ?? null,
|
|
899
904
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
900
905
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -1540,6 +1545,7 @@ var PostgresRegistry = class {
|
|
|
1540
1545
|
creation_schema: row.creationSchema,
|
|
1541
1546
|
inbox_schemas: row.inboxSchemas,
|
|
1542
1547
|
state_schemas: row.stateSchemas,
|
|
1548
|
+
externally_writable_collections: row.externallyWritableCollections ?? void 0,
|
|
1543
1549
|
slash_commands: row.slashCommands ?? void 0,
|
|
1544
1550
|
serve_endpoint: row.serveEndpoint ?? void 0,
|
|
1545
1551
|
default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
|
|
@@ -3469,6 +3475,7 @@ var EntityManager = class {
|
|
|
3469
3475
|
creation_schema: req.creation_schema,
|
|
3470
3476
|
inbox_schemas: req.inbox_schemas,
|
|
3471
3477
|
state_schemas: req.state_schemas,
|
|
3478
|
+
externally_writable_collections: req.externally_writable_collections,
|
|
3472
3479
|
slash_commands: req.slash_commands,
|
|
3473
3480
|
serve_endpoint: req.serve_endpoint,
|
|
3474
3481
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
@@ -4564,6 +4571,40 @@ var EntityManager = class {
|
|
|
4564
4571
|
throw err;
|
|
4565
4572
|
}
|
|
4566
4573
|
}
|
|
4574
|
+
async writeCollection(entityUrl, collection, req) {
|
|
4575
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
4576
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4577
|
+
const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
|
|
4578
|
+
const config = externallyWritableCollections?.[collection];
|
|
4579
|
+
if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
|
|
4580
|
+
const allowedOperations = config.operations ?? [`insert`];
|
|
4581
|
+
if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
|
|
4582
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4583
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
4584
|
+
if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
|
|
4585
|
+
if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
|
|
4586
|
+
const key = req.key ?? `${collection}-${randomUUID()}`;
|
|
4587
|
+
const event = {
|
|
4588
|
+
type: config.type,
|
|
4589
|
+
key,
|
|
4590
|
+
headers: {
|
|
4591
|
+
operation: req.operation,
|
|
4592
|
+
timestamp: new Date().toISOString(),
|
|
4593
|
+
principal: req.principal
|
|
4594
|
+
}
|
|
4595
|
+
};
|
|
4596
|
+
if (req.operation !== `delete`) event.value = req.value;
|
|
4597
|
+
const validationError = await this.validateWriteEvent(entity, event);
|
|
4598
|
+
if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
|
|
4599
|
+
const encoded = this.encodeChangeEvent(event);
|
|
4600
|
+
try {
|
|
4601
|
+
await this.streamClient.append(entity.streams.main, encoded);
|
|
4602
|
+
} catch (err) {
|
|
4603
|
+
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4604
|
+
throw err;
|
|
4605
|
+
}
|
|
4606
|
+
return { key };
|
|
4607
|
+
}
|
|
4567
4608
|
async updateInboxMessage(entityUrl, key, req) {
|
|
4568
4609
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4569
4610
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -5256,7 +5297,8 @@ var EntityManager = class {
|
|
|
5256
5297
|
async getEffectiveSchemas(entity) {
|
|
5257
5298
|
if (!entity.type) return {
|
|
5258
5299
|
inboxSchemas: entity.inbox_schemas,
|
|
5259
|
-
stateSchemas: entity.state_schemas
|
|
5300
|
+
stateSchemas: entity.state_schemas,
|
|
5301
|
+
externallyWritableCollections: void 0
|
|
5260
5302
|
};
|
|
5261
5303
|
const latestType = await this.registry.getEntityType(entity.type);
|
|
5262
5304
|
return {
|
|
@@ -5267,7 +5309,8 @@ var EntityManager = class {
|
|
|
5267
5309
|
stateSchemas: latestType?.state_schemas ? {
|
|
5268
5310
|
...entity.state_schemas ?? {},
|
|
5269
5311
|
...latestType.state_schemas
|
|
5270
|
-
} : entity.state_schemas
|
|
5312
|
+
} : entity.state_schemas,
|
|
5313
|
+
externallyWritableCollections: latestType?.externally_writable_collections
|
|
5271
5314
|
};
|
|
5272
5315
|
}
|
|
5273
5316
|
isClosedStreamError(err) {
|
|
@@ -8505,6 +8548,15 @@ const spawnBodySchema = Type.Object({
|
|
|
8505
8548
|
manifestKey: Type.Optional(Type.String())
|
|
8506
8549
|
}))
|
|
8507
8550
|
});
|
|
8551
|
+
const writeCollectionBodySchema = Type.Object({
|
|
8552
|
+
operation: Type.Union([
|
|
8553
|
+
Type.Literal(`insert`),
|
|
8554
|
+
Type.Literal(`update`),
|
|
8555
|
+
Type.Literal(`delete`)
|
|
8556
|
+
]),
|
|
8557
|
+
key: Type.Optional(Type.String()),
|
|
8558
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
|
|
8559
|
+
}, { additionalProperties: false });
|
|
8508
8560
|
const sendBodySchema = Type.Object({
|
|
8509
8561
|
payload: Type.Optional(Type.Unknown()),
|
|
8510
8562
|
key: Type.Optional(Type.String()),
|
|
@@ -8637,6 +8689,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
|
|
|
8637
8689
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
8638
8690
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
8639
8691
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
8692
|
+
entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
|
|
8640
8693
|
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
8641
8694
|
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
8642
8695
|
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
@@ -9035,6 +9088,23 @@ async function sendEntity(request, ctx) {
|
|
|
9035
9088
|
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
9036
9089
|
return json(result);
|
|
9037
9090
|
}
|
|
9091
|
+
async function writeCollection(request, ctx) {
|
|
9092
|
+
const parsed = routeBody(request);
|
|
9093
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal);
|
|
9094
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
9095
|
+
const collection = request.params.collection;
|
|
9096
|
+
const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
|
|
9097
|
+
operation: parsed.operation,
|
|
9098
|
+
key: parsed.key,
|
|
9099
|
+
value: parsed.value,
|
|
9100
|
+
principal: {
|
|
9101
|
+
url: ctx.principal.url,
|
|
9102
|
+
kind: ctx.principal.kind,
|
|
9103
|
+
id: ctx.principal.id
|
|
9104
|
+
}
|
|
9105
|
+
});
|
|
9106
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
|
|
9107
|
+
}
|
|
9038
9108
|
async function createAttachment(request, ctx) {
|
|
9039
9109
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
9040
9110
|
if (principalMutationError) return principalMutationError;
|
|
@@ -9132,8 +9202,13 @@ async function spawnEntity(request, ctx) {
|
|
|
9132
9202
|
headers: { "x-write-token": entity.write_token }
|
|
9133
9203
|
});
|
|
9134
9204
|
}
|
|
9135
|
-
function getEntity(request) {
|
|
9136
|
-
|
|
9205
|
+
async function getEntity(request, ctx) {
|
|
9206
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
9207
|
+
const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
|
|
9208
|
+
return json({
|
|
9209
|
+
...toPublicEntity(entity),
|
|
9210
|
+
...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
|
|
9211
|
+
});
|
|
9137
9212
|
}
|
|
9138
9213
|
function headEntity() {
|
|
9139
9214
|
return status(200);
|
|
@@ -9168,6 +9243,16 @@ async function signalEntity(request, ctx) {
|
|
|
9168
9243
|
//#region src/routing/entity-types-router.ts
|
|
9169
9244
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
9170
9245
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
9246
|
+
const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
|
|
9247
|
+
type: Type.String(),
|
|
9248
|
+
contract: Type.Optional(Type.String()),
|
|
9249
|
+
operations: Type.Optional(Type.Array(Type.Union([
|
|
9250
|
+
Type.Literal(`insert`),
|
|
9251
|
+
Type.Literal(`update`),
|
|
9252
|
+
Type.Literal(`delete`)
|
|
9253
|
+
]))),
|
|
9254
|
+
principalColumn: Type.Optional(Type.String())
|
|
9255
|
+
}, { additionalProperties: false }));
|
|
9171
9256
|
const slashCommandArgumentSchema = Type.Object({
|
|
9172
9257
|
name: Type.String(),
|
|
9173
9258
|
type: Type.Union([
|
|
@@ -9198,7 +9283,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
9198
9283
|
slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
|
|
9199
9284
|
serve_endpoint: Type.Optional(Type.String()),
|
|
9200
9285
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
9201
|
-
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
9286
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
|
|
9287
|
+
externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
|
|
9202
9288
|
}, { additionalProperties: false });
|
|
9203
9289
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
9204
9290
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -9329,7 +9415,20 @@ function parseExpiresAt(value) {
|
|
|
9329
9415
|
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
9330
9416
|
return expiresAt;
|
|
9331
9417
|
}
|
|
9418
|
+
/**
|
|
9419
|
+
* The `comments` collection name is reserved for the canonical comments
|
|
9420
|
+
* contract: the UI keys its comment affordances on it, so a divergent
|
|
9421
|
+
* collection registered under that name (or the contract mounted under
|
|
9422
|
+
* another name) would break that assumption silently.
|
|
9423
|
+
*/
|
|
9424
|
+
function validateExternallyWritableCollections(collections) {
|
|
9425
|
+
for (const [name, config] of Object.entries(collections ?? {})) {
|
|
9426
|
+
if (name === `comments` && config.contract !== COMMENTS_CONTRACT) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`, 400);
|
|
9427
|
+
if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
|
|
9428
|
+
}
|
|
9429
|
+
}
|
|
9332
9430
|
function normalizeEntityTypeRequest(parsed) {
|
|
9431
|
+
validateExternallyWritableCollections(parsed.externally_writable_collections);
|
|
9333
9432
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
9334
9433
|
return {
|
|
9335
9434
|
name: parsed.name ?? ``,
|
|
@@ -9343,7 +9442,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
9343
9442
|
type: `webhook`,
|
|
9344
9443
|
url: serveEndpoint
|
|
9345
9444
|
}] } : void 0),
|
|
9346
|
-
permission_grants: parsed.permission_grants
|
|
9445
|
+
permission_grants: parsed.permission_grants,
|
|
9446
|
+
externally_writable_collections: parsed.externally_writable_collections
|
|
9347
9447
|
};
|
|
9348
9448
|
}
|
|
9349
9449
|
function toPublicEntityType(entityType) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "entity_types" ADD COLUMN "externally_writable_collections" jsonb;
|
|
@@ -113,6 +113,13 @@
|
|
|
113
113
|
"when": 1779728400000,
|
|
114
114
|
"tag": "0015_pg_sync_bridges",
|
|
115
115
|
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1781200000000,
|
|
121
|
+
"tag": "0016_entity_type_externally_writable_collections",
|
|
122
|
+
"breakpoints": true
|
|
116
123
|
}
|
|
117
124
|
]
|
|
118
125
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"pino-pretty": "^13.0.0",
|
|
55
55
|
"postgres": "^3.4.0",
|
|
56
56
|
"undici": "^7.24.7",
|
|
57
|
-
"@electric-ax/agents-runtime": "0.
|
|
57
|
+
"@electric-ax/agents-runtime": "0.4.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.19.15",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"tsx": "^4.19.0",
|
|
66
66
|
"typescript": "^5.0.0",
|
|
67
67
|
"vitest": "^4.1.0",
|
|
68
|
-
"@electric-ax/agents": "0.4.
|
|
68
|
+
"@electric-ax/agents": "0.4.18",
|
|
69
69
|
"@electric-ax/agents-server-conformance-tests": "0.1.12",
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.5.0"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/db/schema.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
timestamp,
|
|
15
15
|
unique,
|
|
16
16
|
} from 'drizzle-orm/pg-core'
|
|
17
|
+
import type { ExternallyWritableCollectionConfig } from '../electric-agents-types.js'
|
|
17
18
|
|
|
18
19
|
export const entityTypes = pgTable(
|
|
19
20
|
`entity_types`,
|
|
@@ -24,6 +25,9 @@ export const entityTypes = pgTable(
|
|
|
24
25
|
creationSchema: jsonb(`creation_schema`),
|
|
25
26
|
inboxSchemas: jsonb(`inbox_schemas`),
|
|
26
27
|
stateSchemas: jsonb(`state_schemas`),
|
|
28
|
+
externallyWritableCollections: jsonb(
|
|
29
|
+
`externally_writable_collections`
|
|
30
|
+
).$type<Record<string, ExternallyWritableCollectionConfig>>(),
|
|
27
31
|
slashCommands: jsonb(`slash_commands`),
|
|
28
32
|
serveEndpoint: text(`serve_endpoint`),
|
|
29
33
|
defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
|
|
@@ -494,12 +494,31 @@ export function toPublicEntity(
|
|
|
494
494
|
}
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/** Per-collection config making an entity-state collection externally writable via the router. */
|
|
498
|
+
export interface ExternallyWritableCollectionConfig {
|
|
499
|
+
/** Durable-stream event type for this collection, e.g. `state:comments`. */
|
|
500
|
+
type: string
|
|
501
|
+
/** Well-known contract this collection implements, e.g. `comments/v1`. */
|
|
502
|
+
contract?: string
|
|
503
|
+
/**
|
|
504
|
+
* Allowlist of external write operations. When set, the router rejects any
|
|
505
|
+
* operation not listed (403). When unset, only `insert` is permitted — the
|
|
506
|
+
* safe default, since open update/delete lets a client overwrite or remove
|
|
507
|
+
* another principal's rows by key.
|
|
508
|
+
*/
|
|
509
|
+
operations?: Array<`insert` | `update` | `delete`>
|
|
510
|
+
}
|
|
511
|
+
|
|
497
512
|
export interface ElectricAgentsEntityType {
|
|
498
513
|
name: string
|
|
499
514
|
description: string
|
|
500
515
|
creation_schema?: Record<string, unknown>
|
|
501
516
|
inbox_schemas?: Record<string, Record<string, unknown>>
|
|
502
517
|
state_schemas?: Record<string, Record<string, unknown>>
|
|
518
|
+
externally_writable_collections?: Record<
|
|
519
|
+
string,
|
|
520
|
+
ExternallyWritableCollectionConfig
|
|
521
|
+
>
|
|
503
522
|
slash_commands?: Array<SlashCommandDefinition>
|
|
504
523
|
serve_endpoint?: string
|
|
505
524
|
default_dispatch_policy?: DispatchPolicy
|
|
@@ -514,6 +533,10 @@ export interface RegisterEntityTypeRequest {
|
|
|
514
533
|
creation_schema?: Record<string, unknown>
|
|
515
534
|
inbox_schemas?: Record<string, Record<string, unknown>>
|
|
516
535
|
state_schemas?: Record<string, Record<string, unknown>>
|
|
536
|
+
externally_writable_collections?: Record<
|
|
537
|
+
string,
|
|
538
|
+
ExternallyWritableCollectionConfig
|
|
539
|
+
>
|
|
517
540
|
slash_commands?: Array<SlashCommandDefinition>
|
|
518
541
|
serve_endpoint?: string
|
|
519
542
|
default_dispatch_policy?: DispatchPolicy
|
package/src/entity-manager.ts
CHANGED
|
@@ -71,6 +71,7 @@ import type {
|
|
|
71
71
|
SignalRequest,
|
|
72
72
|
SignalResponse,
|
|
73
73
|
TypedSpawnRequest,
|
|
74
|
+
ExternallyWritableCollectionConfig,
|
|
74
75
|
} from './electric-agents-types.js'
|
|
75
76
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
76
77
|
import type { Principal } from './principal.js'
|
|
@@ -131,6 +132,23 @@ export interface CreateAttachmentRequest {
|
|
|
131
132
|
meta?: Record<string, unknown>
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
export interface WriteCollectionPrincipal {
|
|
136
|
+
url: string
|
|
137
|
+
kind: string
|
|
138
|
+
id: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface WriteCollectionRequest {
|
|
142
|
+
operation: `insert` | `update` | `delete`
|
|
143
|
+
key?: string
|
|
144
|
+
value?: Record<string, unknown>
|
|
145
|
+
principal: WriteCollectionPrincipal
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface WriteCollectionResult {
|
|
149
|
+
key: string
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
export interface ReadAttachmentResult {
|
|
135
153
|
attachment: ManifestAttachmentEntry
|
|
136
154
|
bytes: Uint8Array
|
|
@@ -488,6 +506,7 @@ export class EntityManager {
|
|
|
488
506
|
creation_schema: req.creation_schema,
|
|
489
507
|
inbox_schemas: req.inbox_schemas,
|
|
490
508
|
state_schemas: req.state_schemas,
|
|
509
|
+
externally_writable_collections: req.externally_writable_collections,
|
|
491
510
|
slash_commands: req.slash_commands,
|
|
492
511
|
serve_endpoint: req.serve_endpoint,
|
|
493
512
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
@@ -2434,6 +2453,106 @@ export class EntityManager {
|
|
|
2434
2453
|
}
|
|
2435
2454
|
}
|
|
2436
2455
|
|
|
2456
|
+
async writeCollection(
|
|
2457
|
+
entityUrl: string,
|
|
2458
|
+
collection: string,
|
|
2459
|
+
req: WriteCollectionRequest
|
|
2460
|
+
): Promise<WriteCollectionResult> {
|
|
2461
|
+
const entity = await this.registry.getEntity(entityUrl)
|
|
2462
|
+
if (!entity) {
|
|
2463
|
+
throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
const { externallyWritableCollections } =
|
|
2467
|
+
await this.getEffectiveSchemas(entity)
|
|
2468
|
+
const config = externallyWritableCollections?.[collection]
|
|
2469
|
+
if (!config) {
|
|
2470
|
+
throw new ElectricAgentsError(
|
|
2471
|
+
ErrCodeUnauthorized,
|
|
2472
|
+
`Collection "${collection}" is not writable`,
|
|
2473
|
+
403
|
|
2474
|
+
)
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
const allowedOperations = config.operations ?? [`insert`]
|
|
2478
|
+
if (!allowedOperations.includes(req.operation)) {
|
|
2479
|
+
throw new ElectricAgentsError(
|
|
2480
|
+
ErrCodeUnauthorized,
|
|
2481
|
+
`Operation "${req.operation}" is not allowed on collection "${collection}"`,
|
|
2482
|
+
403
|
|
2483
|
+
)
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
if (rejectsNormalWrites(entity.status)) {
|
|
2487
|
+
throw new ElectricAgentsError(
|
|
2488
|
+
ErrCodeNotRunning,
|
|
2489
|
+
`Entity is not accepting writes`,
|
|
2490
|
+
409
|
|
2491
|
+
)
|
|
2492
|
+
}
|
|
2493
|
+
if (this.isForkWorkLockedEntity(entityUrl)) {
|
|
2494
|
+
this.assertEntityNotForkWorkLocked(entityUrl)
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (
|
|
2498
|
+
req.operation !== `delete` &&
|
|
2499
|
+
(req.value === undefined || req.value === null)
|
|
2500
|
+
) {
|
|
2501
|
+
throw new ElectricAgentsError(
|
|
2502
|
+
ErrCodeInvalidRequest,
|
|
2503
|
+
`value is required for ${req.operation}`,
|
|
2504
|
+
400
|
|
2505
|
+
)
|
|
2506
|
+
}
|
|
2507
|
+
if (req.operation !== `insert` && !req.key) {
|
|
2508
|
+
throw new ElectricAgentsError(
|
|
2509
|
+
ErrCodeInvalidRequest,
|
|
2510
|
+
`key is required for ${req.operation}`,
|
|
2511
|
+
400
|
|
2512
|
+
)
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const key = req.key ?? `${collection}-${randomUUID()}`
|
|
2516
|
+
|
|
2517
|
+
const event: Record<string, unknown> = {
|
|
2518
|
+
type: config.type,
|
|
2519
|
+
key,
|
|
2520
|
+
headers: {
|
|
2521
|
+
operation: req.operation,
|
|
2522
|
+
timestamp: new Date().toISOString(),
|
|
2523
|
+
principal: req.principal,
|
|
2524
|
+
},
|
|
2525
|
+
}
|
|
2526
|
+
if (req.operation !== `delete`) {
|
|
2527
|
+
event.value = req.value
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const validationError = await this.validateWriteEvent(entity, event)
|
|
2531
|
+
if (validationError) {
|
|
2532
|
+
throw new ElectricAgentsError(
|
|
2533
|
+
validationError.code,
|
|
2534
|
+
validationError.message,
|
|
2535
|
+
validationError.status
|
|
2536
|
+
)
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
const encoded = this.encodeChangeEvent(event)
|
|
2540
|
+
try {
|
|
2541
|
+
await this.streamClient.append(entity.streams.main, encoded)
|
|
2542
|
+
} catch (err) {
|
|
2543
|
+
if (this.isClosedStreamError(err)) {
|
|
2544
|
+
throw new ElectricAgentsError(
|
|
2545
|
+
ErrCodeNotRunning,
|
|
2546
|
+
`Entity is stopped`,
|
|
2547
|
+
409
|
|
2548
|
+
)
|
|
2549
|
+
}
|
|
2550
|
+
throw err
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
return { key }
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2437
2556
|
async updateInboxMessage(
|
|
2438
2557
|
entityUrl: string,
|
|
2439
2558
|
key: string,
|
|
@@ -3876,11 +3995,16 @@ export class EntityManager {
|
|
|
3876
3995
|
private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{
|
|
3877
3996
|
inboxSchemas?: Record<string, Record<string, unknown>>
|
|
3878
3997
|
stateSchemas?: Record<string, Record<string, unknown>>
|
|
3998
|
+
externallyWritableCollections?: Record<
|
|
3999
|
+
string,
|
|
4000
|
+
ExternallyWritableCollectionConfig
|
|
4001
|
+
>
|
|
3879
4002
|
}> {
|
|
3880
4003
|
if (!entity.type) {
|
|
3881
4004
|
return {
|
|
3882
4005
|
inboxSchemas: entity.inbox_schemas,
|
|
3883
4006
|
stateSchemas: entity.state_schemas,
|
|
4007
|
+
externallyWritableCollections: undefined,
|
|
3884
4008
|
}
|
|
3885
4009
|
}
|
|
3886
4010
|
|
|
@@ -3893,6 +4017,8 @@ export class EntityManager {
|
|
|
3893
4017
|
stateSchemas: latestType?.state_schemas
|
|
3894
4018
|
? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas }
|
|
3895
4019
|
: entity.state_schemas,
|
|
4020
|
+
externallyWritableCollections:
|
|
4021
|
+
latestType?.externally_writable_collections,
|
|
3896
4022
|
}
|
|
3897
4023
|
}
|
|
3898
4024
|
|
package/src/entity-registry.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
EntityTypePermission,
|
|
44
44
|
EntityTypePermissionGrant,
|
|
45
45
|
PermissionSubjectKind,
|
|
46
|
+
ExternallyWritableCollectionConfig,
|
|
46
47
|
} from './electric-agents-types.js'
|
|
47
48
|
import type { EntityTags, PgSyncOptions } from '@electric-ax/agents-runtime'
|
|
48
49
|
import type { Principal } from './principal.js'
|
|
@@ -654,6 +655,8 @@ export class PostgresRegistry {
|
|
|
654
655
|
creationSchema: et.creation_schema ?? null,
|
|
655
656
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
656
657
|
stateSchemas: et.state_schemas ?? null,
|
|
658
|
+
externallyWritableCollections:
|
|
659
|
+
et.externally_writable_collections ?? null,
|
|
657
660
|
slashCommands: et.slash_commands ?? null,
|
|
658
661
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
659
662
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -668,6 +671,8 @@ export class PostgresRegistry {
|
|
|
668
671
|
creationSchema: et.creation_schema ?? null,
|
|
669
672
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
670
673
|
stateSchemas: et.state_schemas ?? null,
|
|
674
|
+
externallyWritableCollections:
|
|
675
|
+
et.externally_writable_collections ?? null,
|
|
671
676
|
slashCommands: et.slash_commands ?? null,
|
|
672
677
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
673
678
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -691,6 +696,8 @@ export class PostgresRegistry {
|
|
|
691
696
|
creationSchema: et.creation_schema ?? null,
|
|
692
697
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
693
698
|
stateSchemas: et.state_schemas ?? null,
|
|
699
|
+
externallyWritableCollections:
|
|
700
|
+
et.externally_writable_collections ?? null,
|
|
694
701
|
slashCommands: et.slash_commands ?? null,
|
|
695
702
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
696
703
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -733,6 +740,8 @@ export class PostgresRegistry {
|
|
|
733
740
|
creationSchema: et.creation_schema ?? null,
|
|
734
741
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
735
742
|
stateSchemas: et.state_schemas ?? null,
|
|
743
|
+
externallyWritableCollections:
|
|
744
|
+
et.externally_writable_collections ?? null,
|
|
736
745
|
slashCommands: et.slash_commands ?? null,
|
|
737
746
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
738
747
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -1957,6 +1966,11 @@ export class PostgresRegistry {
|
|
|
1957
1966
|
state_schemas: row.stateSchemas as
|
|
1958
1967
|
| Record<string, Record<string, unknown>>
|
|
1959
1968
|
| undefined,
|
|
1969
|
+
externally_writable_collections:
|
|
1970
|
+
(row.externallyWritableCollections as Record<
|
|
1971
|
+
string,
|
|
1972
|
+
ExternallyWritableCollectionConfig
|
|
1973
|
+
> | null) ?? undefined,
|
|
1960
1974
|
slash_commands:
|
|
1961
1975
|
(row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
|
|
1962
1976
|
undefined,
|
|
@@ -149,6 +149,19 @@ const spawnBodySchema = Type.Object({
|
|
|
149
149
|
),
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
const writeCollectionBodySchema = Type.Object(
|
|
153
|
+
{
|
|
154
|
+
operation: Type.Union([
|
|
155
|
+
Type.Literal(`insert`),
|
|
156
|
+
Type.Literal(`update`),
|
|
157
|
+
Type.Literal(`delete`),
|
|
158
|
+
]),
|
|
159
|
+
key: Type.Optional(Type.String()),
|
|
160
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
161
|
+
},
|
|
162
|
+
{ additionalProperties: false }
|
|
163
|
+
)
|
|
164
|
+
|
|
152
165
|
const sendBodySchema = Type.Object({
|
|
153
166
|
payload: Type.Optional(Type.Unknown()),
|
|
154
167
|
key: Type.Optional(Type.String()),
|
|
@@ -328,6 +341,7 @@ const eventSourceSubscriptionBodySchema = Type.Object({
|
|
|
328
341
|
})
|
|
329
342
|
|
|
330
343
|
type SpawnBody = Static<typeof spawnBodySchema>
|
|
344
|
+
type WriteCollectionBody = Static<typeof writeCollectionBodySchema>
|
|
331
345
|
type SendBody = Static<typeof sendBodySchema>
|
|
332
346
|
type InboxMessageBody = Static<typeof inboxMessageBodySchema>
|
|
333
347
|
type ForkBody = Static<typeof forkBodySchema>
|
|
@@ -408,6 +422,13 @@ entitiesRouter.post(
|
|
|
408
422
|
withEntityPermission(`write`),
|
|
409
423
|
sendEntity
|
|
410
424
|
)
|
|
425
|
+
entitiesRouter.post(
|
|
426
|
+
`/:type/:instanceId/collections/:collection`,
|
|
427
|
+
withExistingEntity,
|
|
428
|
+
withSchema(writeCollectionBodySchema),
|
|
429
|
+
withEntityPermission(`write`),
|
|
430
|
+
writeCollection
|
|
431
|
+
)
|
|
411
432
|
entitiesRouter.post(
|
|
412
433
|
`/:type/:instanceId/attachments`,
|
|
413
434
|
withExistingEntity,
|
|
@@ -1308,6 +1329,31 @@ async function sendEntity(
|
|
|
1308
1329
|
return json(result)
|
|
1309
1330
|
}
|
|
1310
1331
|
|
|
1332
|
+
async function writeCollection(
|
|
1333
|
+
request: AgentsRouteRequest,
|
|
1334
|
+
ctx: TenantContext
|
|
1335
|
+
): Promise<Response> {
|
|
1336
|
+
const parsed = routeBody<WriteCollectionBody>(request)
|
|
1337
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal)
|
|
1338
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1339
|
+
const collection = request.params.collection
|
|
1340
|
+
const result = await ctx.entityManager.writeCollection(
|
|
1341
|
+
entityUrl,
|
|
1342
|
+
collection,
|
|
1343
|
+
{
|
|
1344
|
+
operation: parsed.operation,
|
|
1345
|
+
key: parsed.key,
|
|
1346
|
+
value: parsed.value,
|
|
1347
|
+
principal: {
|
|
1348
|
+
url: ctx.principal.url,
|
|
1349
|
+
kind: ctx.principal.kind,
|
|
1350
|
+
id: ctx.principal.id,
|
|
1351
|
+
},
|
|
1352
|
+
}
|
|
1353
|
+
)
|
|
1354
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 })
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1311
1357
|
async function createAttachment(
|
|
1312
1358
|
request: AgentsRouteRequest,
|
|
1313
1359
|
ctx: TenantContext
|
|
@@ -1473,8 +1519,21 @@ async function spawnEntity(
|
|
|
1473
1519
|
)
|
|
1474
1520
|
}
|
|
1475
1521
|
|
|
1476
|
-
function getEntity(
|
|
1477
|
-
|
|
1522
|
+
async function getEntity(
|
|
1523
|
+
request: AgentsRouteRequest,
|
|
1524
|
+
ctx: TenantContext
|
|
1525
|
+
): Promise<Response> {
|
|
1526
|
+
const { entity } = requireExistingEntityRoute(request)
|
|
1527
|
+
const entityType = entity.type
|
|
1528
|
+
? await ctx.entityManager.registry.getEntityType(entity.type)
|
|
1529
|
+
: null
|
|
1530
|
+
return json({
|
|
1531
|
+
...toPublicEntity(entity),
|
|
1532
|
+
...(entityType?.externally_writable_collections && {
|
|
1533
|
+
externally_writable_collections:
|
|
1534
|
+
entityType.externally_writable_collections,
|
|
1535
|
+
}),
|
|
1536
|
+
})
|
|
1478
1537
|
}
|
|
1479
1538
|
|
|
1480
1539
|
function headEntity(): Response {
|