@electric-ax/agents-server 0.4.7 → 0.4.11

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/index.js CHANGED
@@ -8,7 +8,7 @@ import postgres from "postgres";
8
8
  import { and, desc, eq, 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 { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
11
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, 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";
@@ -422,7 +422,7 @@ const ErrCodeForkInProgress = `FORK_IN_PROGRESS`;
422
422
  const ErrCodeForkWaitTimeout = `FORK_WAIT_TIMEOUT`;
423
423
  const ErrCodeEntityPersistFailed = `ENTITY_PERSIST_FAILED`;
424
424
  const ErrCodeSubscriptionNotFound = `SUBSCRIPTION_NOT_FOUND`;
425
- const ErrCodeCallbackNotFound = `CALLBACK_NOT_FOUND`;
425
+ const ErrCodeWakeCallbackNotFound = `WAKE_CALLBACK_NOT_FOUND`;
426
426
 
427
427
  //#endregion
428
428
  //#region src/tenant.ts
@@ -2503,7 +2503,7 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2503
2503
  }
2504
2504
  const webhookUrl = rewriteLoopbackWebhookUrl(target.url);
2505
2505
  if (!webhookUrl) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Webhook dispatch target must include a valid URL`, 400);
2506
- const forwardUrl = appendPathToUrl(ctx.publicUrl, `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`);
2506
+ const forwardUrl = appendPathToUrl(ctx.publicUrl, `/_electric/subscription-webhooks/${encodeURIComponent(subscriptionId)}`);
2507
2507
  await ensureSubscriptionIncludesStream(ctx, subscriptionId, streamPath, {
2508
2508
  type: `webhook`,
2509
2509
  streams: [streamPath],
@@ -2624,6 +2624,10 @@ function extractManifestSourceUrl(manifest) {
2624
2624
  }
2625
2625
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2626
2626
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
2627
+ if (manifest.sourceType === `webhook`) {
2628
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2629
+ if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2630
+ }
2627
2631
  return void 0;
2628
2632
  }
2629
2633
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
@@ -2910,7 +2914,8 @@ var EntityManager = class {
2910
2914
  debounceMs: req.wake.debounceMs,
2911
2915
  timeoutMs: req.wake.timeoutMs,
2912
2916
  oneShot: false,
2913
- includeResponse: req.wake.includeResponse
2917
+ includeResponse: req.wake.includeResponse,
2918
+ manifestKey: req.wake.manifestKey
2914
2919
  });
2915
2920
  const contentType = `application/json`;
2916
2921
  const createdEvent = entityStateSchema.entityCreated.insert({
@@ -3583,7 +3588,7 @@ var EntityManager = class {
3583
3588
  };
3584
3589
  }
3585
3590
  /**
3586
- * Deliver a message to an entity's main stream, with optional input schema
3591
+ * Deliver a message to an entity's main stream, with optional inbox schema
3587
3592
  * validation.
3588
3593
  */
3589
3594
  async send(entityUrl, req, opts) {
@@ -3671,7 +3676,7 @@ var EntityManager = class {
3671
3676
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3672
3677
  return updated;
3673
3678
  }
3674
- async removeTag(entityUrl, key, token) {
3679
+ async deleteTag(entityUrl, key, token) {
3675
3680
  const entity = await this.registry.getEntity(entityUrl);
3676
3681
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3677
3682
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
@@ -3682,7 +3687,7 @@ var EntityManager = class {
3682
3687
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3683
3688
  return updated;
3684
3689
  }
3685
- async registerEntitiesSource(tags) {
3690
+ async ensureEntitiesMembershipStream(tags) {
3686
3691
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
3687
3692
  return this.entityBridgeManager.register(this.validateTags(tags));
3688
3693
  }
@@ -3804,6 +3809,35 @@ var EntityManager = class {
3804
3809
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3805
3810
  return { txid };
3806
3811
  }
3812
+ async upsertEventSourceSubscription(entityUrl, req) {
3813
+ const manifestKey = req.subscription.manifestKey;
3814
+ const txid = randomUUID();
3815
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3816
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3817
+ await this.wakeRegistry.register({
3818
+ tenantId: this.tenantId,
3819
+ subscriberUrl: entityUrl,
3820
+ sourceUrl: req.subscription.sourceUrl,
3821
+ condition: {
3822
+ on: `change`,
3823
+ collections: [`webhook_event`],
3824
+ ops: [`insert`]
3825
+ },
3826
+ oneShot: false,
3827
+ manifestKey
3828
+ });
3829
+ return {
3830
+ txid,
3831
+ subscription: req.subscription
3832
+ };
3833
+ }
3834
+ async deleteEventSourceSubscription(entityUrl, req) {
3835
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id);
3836
+ const txid = randomUUID();
3837
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3838
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3839
+ return { txid };
3840
+ }
3807
3841
  /**
3808
3842
  * Register a wake subscription from a subscriber to a source entity.
3809
3843
  */
@@ -4082,7 +4116,7 @@ var EntityManager = class {
4082
4116
  return null;
4083
4117
  }
4084
4118
  /**
4085
- * Add new input/output schema keys to an entity type directly in Postgres.
4119
+ * Add new inbox/state schema keys to an entity type directly in Postgres.
4086
4120
  */
4087
4121
  async amendSchemas(typeName, schemas) {
4088
4122
  if (typeName === `principal`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Entity type "principal" is built in and cannot be amended`, 400);
@@ -4122,7 +4156,7 @@ var EntityManager = class {
4122
4156
  }
4123
4157
  /**
4124
4158
  * Enrich webhook payload with entity context.
4125
- * Called by ElectricAgentsServer during webhook forwarding to inject entity context.
4159
+ * Called by ElectricAgentsServer during subscription webhook dispatch to inject entity context.
4126
4160
  */
4127
4161
  async enrichPayload(payload, consumer) {
4128
4162
  const entity = await this.registry.getEntityByStream(consumer.primary_stream);
@@ -5381,7 +5415,7 @@ var WakeRegistry = class {
5381
5415
  try {
5382
5416
  for (const message of messages) {
5383
5417
  await this.applyShapeMessage(message);
5384
- if (!settled && `control` in message.headers && message.headers.control === `up-to-date`) {
5418
+ if (!settled && isControlMessage(message) && message.headers.control === `up-to-date`) {
5385
5419
  settled = true;
5386
5420
  resolve$1();
5387
5421
  }
@@ -6168,7 +6202,7 @@ function validateParsedBody(schema, parsed) {
6168
6202
  //#region src/routing/durable-streams-routing-adapter.ts
6169
6203
  function appendSearch(target, source) {
6170
6204
  source.searchParams.forEach((value, key) => {
6171
- if (key !== `service`) target.searchParams.append(key, value);
6205
+ target.searchParams.append(key, value);
6172
6206
  });
6173
6207
  return target;
6174
6208
  }
@@ -6488,7 +6522,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
6488
6522
  let targetWebhookUrl = null;
6489
6523
  if (payload.webhook?.url !== void 0) {
6490
6524
  targetWebhookUrl = rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null;
6491
- payload.webhook.url = appendPathToUrl(ctx.publicUrl, `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`);
6525
+ payload.webhook.url = appendPathToUrl(ctx.publicUrl, `/_electric/subscription-webhooks/${encodeURIComponent(subscriptionId)}`);
6492
6526
  }
6493
6527
  rewriteSubscriptionBodyForBackend(payload, ctx.service, routingAdapter);
6494
6528
  return {
@@ -6611,20 +6645,6 @@ async function proxyPassThrough(request, ctx) {
6611
6645
  }
6612
6646
  }
6613
6647
 
6614
- //#endregion
6615
- //#region src/routing/cron-router.ts
6616
- const cronRegisterBodySchema = Type.Object({
6617
- expression: Type.String(),
6618
- timezone: Type.Optional(Type.String())
6619
- });
6620
- const cronRouter = Router({ base: `/_electric/cron` });
6621
- cronRouter.post(`/register`, withSchema(cronRegisterBodySchema), registerCron);
6622
- async function registerCron(request, ctx) {
6623
- const parsed = routeBody(request);
6624
- const streamPath = await ctx.entityManager.getOrCreateCronStream(parsed.expression, parsed.timezone);
6625
- return json({ streamUrl: streamPath });
6626
- }
6627
-
6628
6648
  //#endregion
6629
6649
  //#region src/routing/electric-proxy-router.ts
6630
6650
  const electricProxyRouter = Router({ base: `/_electric/electric` });
@@ -6658,7 +6678,7 @@ async function proxyElectric(request, ctx) {
6658
6678
 
6659
6679
  //#endregion
6660
6680
  //#region src/routing/entities-router.ts
6661
- const stringRecordSchema = Type.Record(Type.String(), Type.String());
6681
+ const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
6662
6682
  function writeTokenFromRequest(request) {
6663
6683
  const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
6664
6684
  if (electricClaimToken) return electricClaimToken;
@@ -6675,7 +6695,7 @@ const wakeConditionSchema = Type.Union([Type.Literal(`runFinished`), Type.Object
6675
6695
  })]);
6676
6696
  const spawnBodySchema = Type.Object({
6677
6697
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6678
- tags: Type.Optional(stringRecordSchema),
6698
+ tags: Type.Optional(stringRecordSchema$1),
6679
6699
  parent: Type.Optional(Type.String()),
6680
6700
  dispatch_policy: Type.Optional(dispatchPolicySchema),
6681
6701
  initialMessage: Type.Optional(Type.Unknown()),
@@ -6684,7 +6704,8 @@ const spawnBodySchema = Type.Object({
6684
6704
  condition: wakeConditionSchema,
6685
6705
  debounceMs: Type.Optional(Type.Number()),
6686
6706
  timeoutMs: Type.Optional(Type.Number()),
6687
- includeResponse: Type.Optional(Type.Boolean())
6707
+ includeResponse: Type.Optional(Type.Boolean()),
6708
+ manifestKey: Type.Optional(Type.String())
6688
6709
  }))
6689
6710
  });
6690
6711
  const sendBodySchema = Type.Object({
@@ -6750,10 +6771,24 @@ const scheduleBodySchema = Type.Union([Type.Object({
6750
6771
  messageType: Type.Optional(Type.String()),
6751
6772
  from: Type.Optional(Type.String())
6752
6773
  })]);
6753
- const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
6774
+ const subscriptionLifetimeSchema = Type.Union([
6775
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
6776
+ Type.Object({
6777
+ kind: Type.Literal(`expires_at`),
6778
+ at: Type.String()
6779
+ }),
6780
+ Type.Object({ kind: Type.Literal(`manual`) })
6781
+ ]);
6782
+ const eventSourceSubscriptionBodySchema = Type.Object({
6783
+ sourceKey: Type.String(),
6784
+ bucketKey: Type.Optional(Type.String()),
6785
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6786
+ filterKey: Type.Optional(Type.String()),
6787
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
6788
+ reason: Type.Optional(Type.String())
6789
+ });
6754
6790
  const entitiesRouter = Router({ base: `/_electric/entities` });
6755
6791
  entitiesRouter.get(`/`, listEntities);
6756
- entitiesRouter.post(`/register`, withSchema(entitiesRegisterBodySchema), registerEntitiesSource);
6757
6792
  entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
6758
6793
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6759
6794
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
@@ -6764,9 +6799,11 @@ entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity,
6764
6799
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
6765
6800
  entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
6766
6801
  entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), setTag);
6767
- entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
6802
+ entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, deleteTag);
6768
6803
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6769
6804
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6805
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6806
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
6770
6807
  function entityUrlFromSegments(type, instanceId) {
6771
6808
  if (!type || !instanceId) return null;
6772
6809
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -6830,11 +6867,6 @@ async function listEntities({ query }, ctx) {
6830
6867
  });
6831
6868
  return json(entities$1.map((entity) => toPublicEntity(entity)));
6832
6869
  }
6833
- async function registerEntitiesSource(request, ctx) {
6834
- const parsed = routeBody(request);
6835
- const result = await ctx.entityManager.registerEntitiesSource(parsed.tags ?? {});
6836
- return json(result);
6837
- }
6838
6870
  async function upsertSchedule(request, ctx) {
6839
6871
  const principalMutationError = rejectPrincipalEntityMutation(request, `scheduled`);
6840
6872
  if (principalMutationError) return principalMutationError;
@@ -6873,8 +6905,49 @@ async function deleteSchedule(request, ctx) {
6873
6905
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6874
6906
  return json(result);
6875
6907
  }
6908
+ async function upsertEventSourceSubscription(request, ctx) {
6909
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6910
+ if (principalMutationError) return principalMutationError;
6911
+ const catalog = ctx.eventSources;
6912
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6913
+ const { entityUrl } = requireExistingEntityRoute(request);
6914
+ const parsed = routeBody(request);
6915
+ const source = await catalog.getEventSource(parsed.sourceKey);
6916
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6917
+ if (parsed.lifetime?.kind === `expires_at`) {
6918
+ const expiresAt = new Date(parsed.lifetime.at);
6919
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6920
+ }
6921
+ let resolved;
6922
+ try {
6923
+ resolved = resolveEventSourceSubscription({
6924
+ contract: source,
6925
+ entityUrl,
6926
+ request: {
6927
+ ...parsed,
6928
+ id: decodeURIComponent(request.params.subscriptionId)
6929
+ },
6930
+ createdBy: `tool`
6931
+ });
6932
+ } catch (error) {
6933
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6934
+ }
6935
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6936
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6937
+ subscription: resolved.subscription,
6938
+ manifest: buildEventSourceManifestEntry(resolved)
6939
+ });
6940
+ return json(result);
6941
+ }
6942
+ async function deleteEventSourceSubscription(request, ctx) {
6943
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6944
+ if (principalMutationError) return principalMutationError;
6945
+ const { entityUrl } = requireExistingEntityRoute(request);
6946
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6947
+ return json(result);
6948
+ }
6876
6949
  async function setTag(request, ctx) {
6877
- const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
6950
+ const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
6878
6951
  if (principalMutationError) return principalMutationError;
6879
6952
  const parsed = routeBody(request);
6880
6953
  const { entityUrl } = requireExistingEntityRoute(request);
@@ -6882,12 +6955,12 @@ async function setTag(request, ctx) {
6882
6955
  const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value }, token);
6883
6956
  return json(toPublicEntity(updated));
6884
6957
  }
6885
- async function removeTag(request, ctx) {
6886
- const principalMutationError = rejectPrincipalEntityMutation(request, `untagged`);
6958
+ async function deleteTag(request, ctx) {
6959
+ const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
6887
6960
  if (principalMutationError) return principalMutationError;
6888
6961
  const { entityUrl } = requireExistingEntityRoute(request);
6889
6962
  const token = writeTokenFromRequest(request);
6890
- const updated = await ctx.entityManager.removeTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
6963
+ const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
6891
6964
  return json(toPublicEntity(updated));
6892
6965
  }
6893
6966
  async function forkEntity(request, ctx) {
@@ -7019,17 +7092,13 @@ const registerEntityTypeBodySchema = Type.Object({
7019
7092
  creation_schema: Type.Optional(jsonObjectSchema),
7020
7093
  inbox_schemas: Type.Optional(schemaMapSchema),
7021
7094
  state_schemas: Type.Optional(schemaMapSchema),
7022
- input_schemas: Type.Optional(schemaMapSchema),
7023
- output_schemas: Type.Optional(schemaMapSchema),
7024
7095
  serve_endpoint: Type.Optional(Type.String()),
7025
7096
  default_dispatch_policy: Type.Optional(dispatchPolicySchema)
7026
- });
7097
+ }, { additionalProperties: false });
7027
7098
  const amendEntityTypeSchemasBodySchema = Type.Object({
7028
- input_schemas: Type.Optional(schemaMapSchema),
7029
- output_schemas: Type.Optional(schemaMapSchema),
7030
7099
  inbox_schemas: Type.Optional(schemaMapSchema),
7031
7100
  state_schemas: Type.Optional(schemaMapSchema)
7032
- });
7101
+ }, { additionalProperties: false });
7033
7102
  const entityTypesRouter = Router({ base: `/_electric/entity-types` });
7034
7103
  entityTypesRouter.get(`/`, listEntityTypes);
7035
7104
  entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), registerEntityType);
@@ -7069,8 +7138,8 @@ async function getEntityType(request, ctx) {
7069
7138
  async function amendSchemas(request, ctx) {
7070
7139
  const parsed = routeBody(request);
7071
7140
  const updated = await ctx.entityManager.amendSchemas(request.params.name, {
7072
- inbox_schemas: parsed.inbox_schemas ?? parsed.input_schemas,
7073
- state_schemas: parsed.state_schemas ?? parsed.output_schemas
7141
+ inbox_schemas: parsed.inbox_schemas,
7142
+ state_schemas: parsed.state_schemas
7074
7143
  });
7075
7144
  return json(toPublicEntityType(updated));
7076
7145
  }
@@ -7080,13 +7149,12 @@ async function deleteEntityType(request, ctx) {
7080
7149
  }
7081
7150
  function normalizeEntityTypeRequest(parsed) {
7082
7151
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
7083
- const compatibilityFields = parsed;
7084
7152
  return {
7085
7153
  name: parsed.name ?? ``,
7086
7154
  description: parsed.description ?? ``,
7087
7155
  creation_schema: parsed.creation_schema,
7088
- inbox_schemas: parsed.inbox_schemas ?? compatibilityFields.input_schemas,
7089
- state_schemas: parsed.state_schemas ?? compatibilityFields.output_schemas,
7156
+ inbox_schemas: parsed.inbox_schemas,
7157
+ state_schemas: parsed.state_schemas,
7090
7158
  serve_endpoint: serveEndpoint,
7091
7159
  default_dispatch_policy: parsed.default_dispatch_policy ?? (serveEndpoint ? { targets: [{
7092
7160
  type: `webhook`,
@@ -7097,8 +7165,6 @@ function normalizeEntityTypeRequest(parsed) {
7097
7165
  function toPublicEntityType(entityType) {
7098
7166
  return {
7099
7167
  ...entityType,
7100
- input_schemas: entityType.inbox_schemas,
7101
- output_schemas: entityType.state_schemas,
7102
7168
  revision: entityType.revision
7103
7169
  };
7104
7170
  }
@@ -7182,13 +7248,35 @@ function errorMapper(err, req) {
7182
7248
  function rejectIfShuttingDown(req, ctx) {
7183
7249
  if (!ctx.isShuttingDown()) return void 0;
7184
7250
  const path$1 = new URL(req.url).pathname;
7185
- if (!path$1.startsWith(`/_electric/webhook-forward/`)) return void 0;
7251
+ if (!path$1.startsWith(`/_electric/subscription-webhooks/`)) return void 0;
7186
7252
  return apiError(503, `SERVER_STOPPING`, `Server is shutting down`);
7187
7253
  }
7188
7254
  function getRequestSpan(req) {
7189
7255
  return carrier(req)[SPAN_KEY];
7190
7256
  }
7191
7257
 
7258
+ //#endregion
7259
+ //#region src/routing/observations-router.ts
7260
+ const stringRecordSchema = Type.Record(Type.String(), Type.String());
7261
+ const ensureEntitiesMembershipStreamBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
7262
+ const ensureCronStreamBodySchema = Type.Object({
7263
+ expression: Type.String(),
7264
+ timezone: Type.Optional(Type.String())
7265
+ });
7266
+ const observationsRouter = Router({ base: `/_electric/observations` });
7267
+ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMembershipStreamBodySchema), ensureEntitiesMembershipStream);
7268
+ observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
7269
+ async function ensureEntitiesMembershipStream(request, ctx) {
7270
+ const parsed = routeBody(request);
7271
+ const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {});
7272
+ return json(result);
7273
+ }
7274
+ async function ensureCronStream(request, ctx) {
7275
+ const parsed = routeBody(request);
7276
+ const streamPath = await ctx.entityManager.getOrCreateCronStream(parsed.expression, parsed.timezone);
7277
+ return json({ streamUrl: streamPath });
7278
+ }
7279
+
7192
7280
  //#endregion
7193
7281
  //#region src/routing/tenant-stream-paths.ts
7194
7282
  function withLeadingSlash(path$1) {
@@ -7527,7 +7615,7 @@ async function notificationFromClaim(ctx, input) {
7527
7615
  wakeId: input.claim.wake_id,
7528
7616
  streamPath: primaryStream,
7529
7617
  streams: streams$1,
7530
- callback: appendPathToUrl(ctx.publicUrl, `/_electric/callback-forward/${encodeURIComponent(input.claim.wake_id)}`),
7618
+ callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
7531
7619
  claimToken: input.claim.token,
7532
7620
  triggerEvent: `message_received`,
7533
7621
  entity: {
@@ -7562,7 +7650,7 @@ const wakeRegistrationBodySchema = Type.Object({
7562
7650
  includeResponse: Type.Optional(Type.Boolean()),
7563
7651
  manifestKey: Type.Optional(Type.String())
7564
7652
  });
7565
- const webhookForwardBodySchema = Type.Object({
7653
+ const subscriptionWebhookBodySchema = Type.Object({
7566
7654
  subscription_id: Type.Optional(Type.String()),
7567
7655
  wake_id: Type.Optional(Type.String()),
7568
7656
  generation: Type.Optional(Type.Number()),
@@ -7576,7 +7664,7 @@ const webhookForwardBodySchema = Type.Object({
7576
7664
  consumer_id: Type.Optional(Type.String()),
7577
7665
  callback: Type.Optional(Type.String())
7578
7666
  }, { additionalProperties: true });
7579
- const callbackForwardBodySchema = Type.Object({
7667
+ const wakeCallbackBodySchema = Type.Object({
7580
7668
  epoch: Type.Optional(Type.Number()),
7581
7669
  generation: Type.Optional(Type.Number()),
7582
7670
  wakeId: Type.Optional(Type.String()),
@@ -7587,14 +7675,15 @@ const callbackForwardBodySchema = Type.Object({
7587
7675
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7588
7676
  const internalRouter = Router({ base: `/_electric` });
7589
7677
  internalRouter.get(`/health`, () => json({ status: `ok` }));
7678
+ internalRouter.get(`/event-sources`, listEventSources);
7590
7679
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7591
- internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
7592
- internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
7680
+ internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
7681
+ internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
7593
7682
  internalRouter.all(`/runners`, runnersRouter.fetch);
7594
7683
  internalRouter.all(`/runners/*`, runnersRouter.fetch);
7595
7684
  internalRouter.all(`/entities/*`, entitiesRouter.fetch);
7596
7685
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
7597
- internalRouter.all(`/cron/*`, cronRouter.fetch);
7686
+ internalRouter.all(`/observations/*`, observationsRouter.fetch);
7598
7687
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
7599
7688
  internalRouter.all(`*`, () => status(404));
7600
7689
  function routeParam(request, name) {
@@ -7627,13 +7716,30 @@ function resolveWebhookSigner(ctx) {
7627
7716
  return ctx.webhookSigner ?? getDefaultWebhookSigner();
7628
7717
  }
7629
7718
  function durableStreamsWebhookJwksUrl(ctx) {
7630
- if (!ctx.durableStreamsRouting) return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
7719
+ if (!ctx.durableStreamsRouting) return appendPathToBackendUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
7631
7720
  return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
7632
7721
  durableStreamsUrl: ctx.durableStreamsUrl,
7633
7722
  serviceId: ctx.service,
7634
7723
  requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`)
7635
7724
  }).toString();
7636
7725
  }
7726
+ function appendPathToBackendUrl(baseUrl, path$1) {
7727
+ const base = new URL(baseUrl);
7728
+ const pathUrl = new URL(path$1, `http://electric-agents.local`);
7729
+ const basePath = base.pathname === `/` ? `` : base.pathname.replace(/\/+$/, ``);
7730
+ const suffix = pathUrl.pathname.startsWith(`/`) ? pathUrl.pathname : `/${pathUrl.pathname}`;
7731
+ const target = new URL(base);
7732
+ target.pathname = `${basePath}${suffix}`;
7733
+ target.search = ``;
7734
+ target.hash = pathUrl.hash;
7735
+ base.searchParams.forEach((value, key) => {
7736
+ target.searchParams.append(key, value);
7737
+ });
7738
+ pathUrl.searchParams.forEach((value, key) => {
7739
+ target.searchParams.append(key, value);
7740
+ });
7741
+ return target.toString();
7742
+ }
7637
7743
  function durableStreamsJwksFetchClient(ctx) {
7638
7744
  return async (input, init) => {
7639
7745
  const headers = new Headers(init?.headers);
@@ -7690,10 +7796,17 @@ async function registerWake(request, ctx) {
7690
7796
  await ctx.entityManager.registerWake(opts);
7691
7797
  return status(204);
7692
7798
  }
7693
- async function webhookForward(request, ctx) {
7799
+ async function listEventSources(_request, ctx) {
7800
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7801
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7802
+ }
7803
+ function isAgentVisibleEventSource(source) {
7804
+ return source.agentVisible === true && source.status === `active`;
7805
+ }
7806
+ async function subscriptionWebhook(request, ctx) {
7694
7807
  const subscriptionId = routeParam(request, `subscriptionId`);
7695
7808
  const rootSpan = getRequestSpan(request);
7696
- rootSpan?.updateName(`webhook-forward`);
7809
+ rootSpan?.updateName(`subscription-webhook`);
7697
7810
  rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
7698
7811
  const body = await readRequestBody(request);
7699
7812
  const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
@@ -7707,7 +7820,7 @@ async function webhookForward(request, ctx) {
7707
7820
  }
7708
7821
  });
7709
7822
  if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
7710
- const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
7823
+ const parsedBodyResult = validateOptionalJsonBody(subscriptionWebhookBodySchema, body, request.headers.get(`content-type`));
7711
7824
  if (!parsedBodyResult.ok) return parsedBodyResult.response;
7712
7825
  let forwardBody = body;
7713
7826
  let runningEntityUrl = null;
@@ -7753,7 +7866,7 @@ async function webhookForward(request, ctx) {
7753
7866
  span.end();
7754
7867
  }
7755
7868
  }).catch((err) => {
7756
- serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7869
+ serverLog.warn(`[subscription-webhook] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7757
7870
  }) : void 0;
7758
7871
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7759
7872
  if (entity?.status === `stopped` || entity?.status === `paused`) {
@@ -7774,7 +7887,7 @@ async function webhookForward(request, ctx) {
7774
7887
  runningEntityUrl = entity.url;
7775
7888
  }
7776
7889
  if (consumerId && callbackUrl) {
7777
- const callback = appendPathToUrl(ctx.publicUrl, `/_electric/callback-forward/${encodeURIComponent(consumerId)}`);
7890
+ const callback = appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(consumerId)}`);
7778
7891
  enriched.callback = callback;
7779
7892
  if (newWebhook) {
7780
7893
  enriched.consumerId = newWebhook.wakeId;
@@ -7811,21 +7924,21 @@ async function webhookForward(request, ctx) {
7811
7924
  });
7812
7925
  } catch (err) {
7813
7926
  if (runningEntityUrl) await ctx.entityManager.registry.updateStatus(runningEntityUrl, `idle`);
7814
- return apiError(502, `WEBHOOK_FORWARD_FAILED`, err instanceof Error ? err.message : String(err));
7927
+ return apiError(502, `SUBSCRIPTION_WEBHOOK_FAILED`, err instanceof Error ? err.message : String(err));
7815
7928
  }
7816
7929
  const responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
7817
7930
  return responseFromUpstream(upstream, responseBytes);
7818
7931
  }
7819
- async function callbackForward(request, ctx) {
7932
+ async function wakeCallback(request, ctx) {
7820
7933
  const consumerId = routeParam(request, `consumerId`);
7821
7934
  const rows = await ctx.pgDb.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, ctx.service), eq(consumerCallbacks.consumerId, consumerId))).limit(1);
7822
7935
  const target = rows[0] ? {
7823
7936
  callbackUrl: rows[0].callbackUrl,
7824
7937
  primaryStream: rows[0].primaryStream
7825
7938
  } : void 0;
7826
- if (!target) return apiError(404, ErrCodeCallbackNotFound, `Unknown callback-forward consumer`);
7939
+ if (!target) return apiError(404, ErrCodeWakeCallbackNotFound, `Unknown wake-callback consumer`);
7827
7940
  const body = await readRequestBody(request);
7828
- const parsedBodyResult = validateOptionalJsonBody(callbackForwardBodySchema, body, request.headers.get(`content-type`));
7941
+ const parsedBodyResult = validateOptionalJsonBody(wakeCallbackBodySchema, body, request.headers.get(`content-type`));
7829
7942
  if (!parsedBodyResult.ok) return parsedBodyResult.response;
7830
7943
  const requestBody = parsedBodyResult.value;
7831
7944
  const isClaimRequest = requestBody?.wakeId !== void 0 || requestBody?.wake_id !== void 0;
@@ -7843,14 +7956,14 @@ async function callbackForward(request, ctx) {
7843
7956
  }
7844
7957
  return json(responseBody);
7845
7958
  }
7846
- const upstreamBody = encodeCallbackForwardBody(ctx.service, consumerId, requestBody, resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl));
7959
+ const upstreamBody = encodeWakeCallbackBody(ctx.service, consumerId, requestBody, resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl));
7847
7960
  let upstream;
7848
7961
  try {
7849
7962
  const subscriptionId = durableStreamsSubscriptionCallback(target.callbackUrl);
7850
7963
  if (subscriptionId) {
7851
7964
  const token = claimTokenFromRequest(request);
7852
7965
  if (!token) return apiError(401, `UNAUTHORIZED`, `Missing claim token`);
7853
- const upstreamPayload = encodeCallbackForwardPayload(consumerId, requestBody, (stream) => stream.replace(/^\/+/, ``));
7966
+ const upstreamPayload = encodeWakeCallbackPayload(consumerId, requestBody, (stream) => stream.replace(/^\/+/, ``));
7854
7967
  const result = await ctx.streamClient.ackSubscription(subscriptionId, token, upstreamPayload);
7855
7968
  upstream = json(result);
7856
7969
  } else upstream = await fetch(target.callbackUrl, {
@@ -7859,7 +7972,7 @@ async function callbackForward(request, ctx) {
7859
7972
  body: bodyFromBytes(upstreamBody)
7860
7973
  });
7861
7974
  } catch (err) {
7862
- return apiError(502, `CALLBACK_FORWARD_FAILED`, err instanceof Error ? err.message : String(err));
7975
+ return apiError(502, `WAKE_CALLBACK_FAILED`, err instanceof Error ? err.message : String(err));
7863
7976
  }
7864
7977
  let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
7865
7978
  if (isClaimRequest && upstream.ok && target.primaryStream) {
@@ -7879,7 +7992,7 @@ async function callbackForward(request, ctx) {
7879
7992
  epoch
7880
7993
  });
7881
7994
  if (upstream.ok && isDoneRequest && target.primaryStream) {
7882
- serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
7995
+ serverLog.info(`[wake-callback] done received for stream=${target.primaryStream} consumer=${consumerId}`);
7883
7996
  const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
7884
7997
  const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
7885
7998
  let entityCleared = false;
@@ -7901,13 +8014,13 @@ async function callbackForward(request, ctx) {
7901
8014
  if (entity && (entityCleared || stillOwnsClaim)) {
7902
8015
  await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7903
8016
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7904
- serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7905
- } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
8017
+ serverLog.info(`[wake-callback] status updated after done for ${entity.url}`);
8018
+ } else if (!entity) serverLog.warn(`[wake-callback] done received but no entity found for stream=${target.primaryStream}`);
7906
8019
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7907
- else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
7908
- } else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
8020
+ else if (entity) serverLog.info(`[wake-callback] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
8021
+ } else if (requestBody?.done === true) serverLog.warn(`[wake-callback] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
7909
8022
  } catch (err) {
7910
- serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
8023
+ serverLog.error(`[wake-callback] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
7911
8024
  }
7912
8025
  return responseFromUpstream(upstream, responseBytes);
7913
8026
  }
@@ -7916,11 +8029,11 @@ async function mintClaimWriteToken(ctx, streamPath, consumerId) {
7916
8029
  if (!entity) return void 0;
7917
8030
  return ctx.runtime.claimWriteTokens.mint(ctx.service, streamPath, consumerId);
7918
8031
  }
7919
- function encodeCallbackForwardBody(service, consumerId, body, routingAdapter) {
7920
- const payload = encodeCallbackForwardPayload(consumerId, body, (stream) => routingAdapter.toBackendStreamPath(service, stream));
8032
+ function encodeWakeCallbackBody(service, consumerId, body, routingAdapter) {
8033
+ const payload = encodeWakeCallbackPayload(consumerId, body, (stream) => routingAdapter.toBackendStreamPath(service, stream));
7921
8034
  return new TextEncoder().encode(JSON.stringify(payload));
7922
8035
  }
7923
- function encodeCallbackForwardPayload(consumerId, body, mapStream) {
8036
+ function encodeWakeCallbackPayload(consumerId, body, mapStream) {
7924
8037
  if (!body) return {};
7925
8038
  const generation = body.generation ?? body.epoch;
7926
8039
  const wakeId = body.wake_id ?? body.wakeId ?? consumerId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.7",
3
+ "version": "0.4.11",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
40
  "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@eac712f",
41
41
  "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
- "@electric-sql/client": "^1.5.18",
42
+ "@electric-sql/client": "^1.5.19",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
45
45
  "@sinclair/typebox": "^0.34.48",
@@ -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.3.2"
57
+ "@electric-ax/agents-runtime": "0.3.5"
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-server-conformance-tests": "0.1.7",
69
- "@electric-ax/agents": "0.4.6",
70
- "@electric-ax/agents-server-ui": "0.4.7"
68
+ "@electric-ax/agents": "0.4.9",
69
+ "@electric-ax/agents-server-ui": "0.4.11",
70
+ "@electric-ax/agents-server-conformance-tests": "0.1.8"
71
71
  },
72
72
  "files": [
73
73
  "dist",