@electric-ax/agents-server 0.4.15 → 0.4.16

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.
@@ -1,15 +1,21 @@
1
- import { and, desc, eq, lt, ne, sql } from 'drizzle-orm'
1
+ import { and, desc, eq, inArray, lt, ne, sql } from 'drizzle-orm'
2
2
  import { buildTagsIndex, normalizeTags } from '@electric-ax/agents-runtime'
3
3
  import {
4
4
  consumerClaims,
5
5
  entities,
6
+ entityEffectivePermissions,
6
7
  entityBridges,
7
8
  entityDispatchState,
8
9
  entityManifestSources,
10
+ entityLineage,
11
+ entityPermissionGrants,
9
12
  entityTypes,
13
+ entityTypePermissionGrants,
10
14
  runnerRuntimeDiagnostics,
11
15
  runners,
16
+ sharedStateLinks,
12
17
  tagStreamOutbox,
18
+ users,
13
19
  } from './db/schema.js'
14
20
  import {
15
21
  assertEntityStatus,
@@ -30,8 +36,15 @@ import type {
30
36
  SourceStreamOffset,
31
37
  ConsumerClaim,
32
38
  DispatchPolicy,
39
+ EntityPermission,
40
+ EntityPermissionGrant,
41
+ EntityPermissionPropagation,
42
+ EntityTypePermission,
43
+ EntityTypePermissionGrant,
44
+ PermissionSubjectKind,
33
45
  } from './electric-agents-types.js'
34
46
  import type { EntityTags } from '@electric-ax/agents-runtime'
47
+ import type { Principal } from './principal.js'
35
48
 
36
49
  export class EntityAlreadyExistsError extends Error {
37
50
  constructor(public readonly url: string) {
@@ -51,6 +64,8 @@ export interface EntityBridgeRow {
51
64
  sourceRef: string
52
65
  tags: EntityTags
53
66
  streamUrl: string
67
+ principalUrl?: string
68
+ principalKind?: string
54
69
  shapeHandle?: string
55
70
  shapeOffset?: string
56
71
  lastObserverActivityAt: Date
@@ -135,13 +150,43 @@ export interface MaterializeReleasedClaimInput {
135
150
  releasedAt?: Date
136
151
  }
137
152
 
153
+ export interface CreateEntityTypePermissionGrantInput {
154
+ entityType: string
155
+ permission: EntityTypePermission
156
+ subjectKind: PermissionSubjectKind
157
+ subjectValue: string
158
+ createdBy?: string
159
+ expiresAt?: Date
160
+ }
161
+
162
+ export interface CreateEntityPermissionGrantInput {
163
+ entityUrl: string
164
+ permission: EntityPermission
165
+ subjectKind: PermissionSubjectKind
166
+ subjectValue: string
167
+ propagation?: EntityPermissionPropagation
168
+ copyToChildren?: boolean
169
+ createdBy?: string
170
+ expiresAt?: Date
171
+ }
172
+
138
173
  const DEFAULT_RUNNER_LEASE_MS = 30_000
174
+ const PERMISSION_PRUNE_INTERVAL_MS = 30_000
175
+
176
+ type RegistryTransaction = Parameters<
177
+ Parameters<DrizzleDB[`transaction`]>[0]
178
+ >[0]
139
179
 
140
180
  export function runnerWakeStream(runnerId: string): string {
141
181
  return `/runners/${runnerId}/wake`
142
182
  }
143
183
 
144
184
  export class PostgresRegistry {
185
+ // Electric predicates cannot depend on now(), so expired effective rows need a
186
+ // server-side sweep. Debounce it to keep read/auth paths from writing on every request.
187
+ private lastPermissionPruneStartedAt = 0
188
+ private permissionPrunePromise: Promise<void> | null = null
189
+
145
190
  constructor(
146
191
  private db: DrizzleDB,
147
192
  readonly tenantId: string = DEFAULT_TENANT_ID
@@ -151,6 +196,18 @@ export class PostgresRegistry {
151
196
 
152
197
  close(): void {}
153
198
 
199
+ async ensureUserForPrincipal(principal: Principal): Promise<void> {
200
+ if (principal.kind !== `user`) return
201
+
202
+ await this.db
203
+ .insert(users)
204
+ .values({
205
+ tenantId: this.tenantId,
206
+ id: principal.id,
207
+ })
208
+ .onConflictDoNothing()
209
+ }
210
+
154
211
  async createRunner(
155
212
  input: RegisterRunnerInput
156
213
  ): Promise<ElectricAgentsRunner> {
@@ -699,6 +756,67 @@ export class PostgresRegistry {
699
756
  })
700
757
  .onConflictDoNothing()
701
758
 
759
+ await tx
760
+ .insert(entityLineage)
761
+ .values({
762
+ tenantId: this.tenantId,
763
+ ancestorUrl: entity.url,
764
+ descendantUrl: entity.url,
765
+ depth: 0,
766
+ })
767
+ .onConflictDoNothing()
768
+
769
+ if (entity.parent) {
770
+ await tx.execute(sql`
771
+ INSERT INTO ${entityLineage} (
772
+ tenant_id,
773
+ ancestor_url,
774
+ descendant_url,
775
+ depth
776
+ )
777
+ SELECT
778
+ ${this.tenantId},
779
+ ancestor_url,
780
+ ${entity.url},
781
+ depth + 1
782
+ FROM ${entityLineage}
783
+ WHERE tenant_id = ${this.tenantId}
784
+ AND descendant_url = ${entity.parent}
785
+ ON CONFLICT DO NOTHING
786
+ `)
787
+ }
788
+
789
+ await tx.execute(sql`
790
+ INSERT INTO ${entityEffectivePermissions} (
791
+ tenant_id,
792
+ entity_url,
793
+ source_entity_url,
794
+ source_grant_id,
795
+ permission,
796
+ subject_kind,
797
+ subject_value,
798
+ expires_at
799
+ )
800
+ SELECT
801
+ ${this.tenantId},
802
+ ${entity.url},
803
+ grants.entity_url,
804
+ grants.id,
805
+ grants.permission,
806
+ grants.subject_kind,
807
+ grants.subject_value,
808
+ grants.expires_at
809
+ FROM ${entityPermissionGrants} grants
810
+ JOIN ${entityLineage} lineage
811
+ ON lineage.tenant_id = grants.tenant_id
812
+ AND lineage.ancestor_url = grants.entity_url
813
+ AND lineage.descendant_url = ${entity.url}
814
+ WHERE grants.tenant_id = ${this.tenantId}
815
+ AND grants.propagation = 'descendants'
816
+ AND (grants.expires_at IS NULL OR grants.expires_at > now())
817
+ ON CONFLICT DO NOTHING
818
+ `)
819
+
702
820
  return parseInt(result[0]!.txid)
703
821
  })
704
822
  } catch (err) {
@@ -735,12 +853,9 @@ export class PostgresRegistry {
735
853
  streamPath: string
736
854
  ): Promise<ElectricAgentsEntity | null> {
737
855
  const mainSuffix = `/main`
738
- const errorSuffix = `/error`
739
856
  let entityUrl: string | null = null
740
857
  if (streamPath.endsWith(mainSuffix)) {
741
858
  entityUrl = streamPath.slice(0, -mainSuffix.length)
742
- } else if (streamPath.endsWith(errorSuffix)) {
743
- entityUrl = streamPath.slice(0, -errorSuffix.length)
744
859
  }
745
860
  if (!entityUrl) return null
746
861
  return this.getEntity(entityUrl)
@@ -753,6 +868,11 @@ export class PostgresRegistry {
753
868
  limit?: number
754
869
  offset?: number
755
870
  created_by?: string
871
+ readableBy?: {
872
+ principalUrl: string
873
+ principalKind: string
874
+ bypass?: boolean
875
+ }
756
876
  }): Promise<{ entities: Array<ElectricAgentsEntity>; total: number }> {
757
877
  const conditions = [eq(entities.tenantId, this.tenantId)]
758
878
  if (filter?.type) conditions.push(eq(entities.type, filter.type))
@@ -760,6 +880,25 @@ export class PostgresRegistry {
760
880
  if (filter?.parent) conditions.push(eq(entities.parent, filter.parent))
761
881
  if (filter?.created_by)
762
882
  conditions.push(eq(entities.createdBy, filter.created_by))
883
+ if (filter?.readableBy && !filter.readableBy.bypass) {
884
+ conditions.push(sql`(
885
+ ${entities.createdBy} = ${filter.readableBy.principalUrl}
886
+ OR ${entities.url} IN (
887
+ SELECT ${entityEffectivePermissions.entityUrl}
888
+ FROM ${entityEffectivePermissions}
889
+ WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
890
+ AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
891
+ AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
892
+ AND (
893
+ (${entityEffectivePermissions.subjectKind} = 'principal'
894
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
895
+ OR
896
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
897
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
898
+ )
899
+ )
900
+ )`)
901
+ }
763
902
 
764
903
  const whereClause = and(...conditions)
765
904
 
@@ -790,6 +929,431 @@ export class PostgresRegistry {
790
929
  }
791
930
  }
792
931
 
932
+ async createEntityTypePermissionGrant(
933
+ input: CreateEntityTypePermissionGrantInput
934
+ ): Promise<EntityTypePermissionGrant> {
935
+ const [row] = await this.db
936
+ .insert(entityTypePermissionGrants)
937
+ .values({
938
+ tenantId: this.tenantId,
939
+ entityType: input.entityType,
940
+ permission: input.permission,
941
+ subjectKind: input.subjectKind,
942
+ subjectValue: input.subjectValue,
943
+ createdBy: input.createdBy ?? null,
944
+ expiresAt: input.expiresAt ?? null,
945
+ })
946
+ .returning()
947
+ return this.rowToEntityTypePermissionGrant(row!)
948
+ }
949
+
950
+ async ensureEntityTypePermissionGrant(
951
+ input: CreateEntityTypePermissionGrantInput
952
+ ): Promise<EntityTypePermissionGrant> {
953
+ const [existing] = await this.db
954
+ .select()
955
+ .from(entityTypePermissionGrants)
956
+ .where(
957
+ and(
958
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
959
+ eq(entityTypePermissionGrants.entityType, input.entityType),
960
+ eq(entityTypePermissionGrants.permission, input.permission),
961
+ eq(entityTypePermissionGrants.subjectKind, input.subjectKind),
962
+ eq(entityTypePermissionGrants.subjectValue, input.subjectValue),
963
+ input.expiresAt
964
+ ? eq(entityTypePermissionGrants.expiresAt, input.expiresAt)
965
+ : sql`${entityTypePermissionGrants.expiresAt} IS NULL`
966
+ )
967
+ )
968
+ .limit(1)
969
+ if (existing) return this.rowToEntityTypePermissionGrant(existing)
970
+
971
+ return await this.createEntityTypePermissionGrant(input)
972
+ }
973
+
974
+ async listEntityTypePermissionGrants(
975
+ entityType: string
976
+ ): Promise<Array<EntityTypePermissionGrant>> {
977
+ const rows = await this.db
978
+ .select()
979
+ .from(entityTypePermissionGrants)
980
+ .where(
981
+ and(
982
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
983
+ eq(entityTypePermissionGrants.entityType, entityType)
984
+ )
985
+ )
986
+ .orderBy(entityTypePermissionGrants.id)
987
+ return rows.map((row) => this.rowToEntityTypePermissionGrant(row))
988
+ }
989
+
990
+ async deleteEntityTypePermissionGrant(
991
+ entityType: string,
992
+ grantId: number
993
+ ): Promise<boolean> {
994
+ const rows = await this.db
995
+ .delete(entityTypePermissionGrants)
996
+ .where(
997
+ and(
998
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
999
+ eq(entityTypePermissionGrants.entityType, entityType),
1000
+ eq(entityTypePermissionGrants.id, grantId)
1001
+ )
1002
+ )
1003
+ .returning({ id: entityTypePermissionGrants.id })
1004
+ return rows.length > 0
1005
+ }
1006
+
1007
+ async hasEntityTypePermission(
1008
+ entityType: string,
1009
+ permission: EntityTypePermission,
1010
+ subject: { principalUrl: string; principalKind: string }
1011
+ ): Promise<boolean> {
1012
+ const permissions = [permission, `manage`] as const
1013
+ const rows = await this.db
1014
+ .select({ id: entityTypePermissionGrants.id })
1015
+ .from(entityTypePermissionGrants)
1016
+ .where(
1017
+ and(
1018
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1019
+ eq(entityTypePermissionGrants.entityType, entityType),
1020
+ inArray(entityTypePermissionGrants.permission, [...permissions]),
1021
+ sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`,
1022
+ sql`(
1023
+ (${entityTypePermissionGrants.subjectKind} = 'principal'
1024
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
1025
+ OR
1026
+ (${entityTypePermissionGrants.subjectKind} = 'principal_kind'
1027
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
1028
+ )`
1029
+ )
1030
+ )
1031
+ .limit(1)
1032
+ return rows.length > 0
1033
+ }
1034
+
1035
+ async createEntityPermissionGrant(
1036
+ input: CreateEntityPermissionGrantInput
1037
+ ): Promise<EntityPermissionGrant> {
1038
+ return await this.db.transaction(async (tx) => {
1039
+ const [row] = await tx
1040
+ .insert(entityPermissionGrants)
1041
+ .values({
1042
+ tenantId: this.tenantId,
1043
+ entityUrl: input.entityUrl,
1044
+ permission: input.permission,
1045
+ subjectKind: input.subjectKind,
1046
+ subjectValue: input.subjectValue,
1047
+ propagation: input.propagation ?? `self`,
1048
+ copyToChildren: input.copyToChildren ?? false,
1049
+ createdBy: input.createdBy ?? null,
1050
+ expiresAt: input.expiresAt ?? null,
1051
+ })
1052
+ .returning()
1053
+ await this.materializeEntityPermissionGrant(tx, row!)
1054
+ return this.rowToEntityPermissionGrant(row!)
1055
+ })
1056
+ }
1057
+
1058
+ async listEntityPermissionGrants(
1059
+ entityUrl: string
1060
+ ): Promise<Array<EntityPermissionGrant>> {
1061
+ const rows = await this.db
1062
+ .select()
1063
+ .from(entityPermissionGrants)
1064
+ .where(
1065
+ and(
1066
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1067
+ eq(entityPermissionGrants.entityUrl, entityUrl)
1068
+ )
1069
+ )
1070
+ .orderBy(entityPermissionGrants.id)
1071
+ return rows.map((row) => this.rowToEntityPermissionGrant(row))
1072
+ }
1073
+
1074
+ async deleteEntityPermissionGrant(
1075
+ entityUrl: string,
1076
+ grantId: number
1077
+ ): Promise<boolean> {
1078
+ return await this.db.transaction(async (tx) => {
1079
+ await tx
1080
+ .delete(entityEffectivePermissions)
1081
+ .where(
1082
+ and(
1083
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1084
+ eq(entityEffectivePermissions.sourceGrantId, grantId)
1085
+ )
1086
+ )
1087
+ const rows = await tx
1088
+ .delete(entityPermissionGrants)
1089
+ .where(
1090
+ and(
1091
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1092
+ eq(entityPermissionGrants.entityUrl, entityUrl),
1093
+ eq(entityPermissionGrants.id, grantId)
1094
+ )
1095
+ )
1096
+ .returning({ id: entityPermissionGrants.id })
1097
+ return rows.length > 0
1098
+ })
1099
+ }
1100
+
1101
+ async copyEntityPermissionGrantsForSpawn(
1102
+ parentEntityUrl: string,
1103
+ childEntityUrl: string,
1104
+ createdBy?: string
1105
+ ): Promise<Array<EntityPermissionGrant>> {
1106
+ const parentGrants = await this.db
1107
+ .select()
1108
+ .from(entityPermissionGrants)
1109
+ .where(
1110
+ and(
1111
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1112
+ eq(entityPermissionGrants.entityUrl, parentEntityUrl),
1113
+ eq(entityPermissionGrants.copyToChildren, true),
1114
+ sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`
1115
+ )
1116
+ )
1117
+
1118
+ const copied: Array<EntityPermissionGrant> = []
1119
+ for (const grant of parentGrants) {
1120
+ copied.push(
1121
+ await this.createEntityPermissionGrant({
1122
+ entityUrl: childEntityUrl,
1123
+ permission: grant.permission as EntityPermission,
1124
+ subjectKind: grant.subjectKind as PermissionSubjectKind,
1125
+ subjectValue: grant.subjectValue,
1126
+ propagation: `self`,
1127
+ copyToChildren: grant.copyToChildren,
1128
+ createdBy,
1129
+ expiresAt: grant.expiresAt ?? undefined,
1130
+ })
1131
+ )
1132
+ }
1133
+ return copied
1134
+ }
1135
+
1136
+ async hasEntityPermission(
1137
+ entityUrl: string,
1138
+ permission: EntityPermission,
1139
+ subject: { principalUrl: string; principalKind: string }
1140
+ ): Promise<boolean> {
1141
+ const permissions = [permission, `manage`] as const
1142
+ const rows = await this.db
1143
+ .select({ id: entityEffectivePermissions.id })
1144
+ .from(entityEffectivePermissions)
1145
+ .where(
1146
+ and(
1147
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1148
+ eq(entityEffectivePermissions.entityUrl, entityUrl),
1149
+ inArray(entityEffectivePermissions.permission, [...permissions]),
1150
+ sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`,
1151
+ sql`(
1152
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1153
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
1154
+ OR
1155
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1156
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
1157
+ )`
1158
+ )
1159
+ )
1160
+ .limit(1)
1161
+ return rows.length > 0
1162
+ }
1163
+
1164
+ async replaceSharedStateLink(
1165
+ ownerEntityUrl: string,
1166
+ manifestKey: string,
1167
+ sharedStateId?: string
1168
+ ): Promise<void> {
1169
+ await this.db
1170
+ .delete(sharedStateLinks)
1171
+ .where(
1172
+ and(
1173
+ eq(sharedStateLinks.tenantId, this.tenantId),
1174
+ eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl),
1175
+ eq(sharedStateLinks.manifestKey, manifestKey)
1176
+ )
1177
+ )
1178
+
1179
+ if (!sharedStateId) return
1180
+
1181
+ await this.db
1182
+ .insert(sharedStateLinks)
1183
+ .values({
1184
+ tenantId: this.tenantId,
1185
+ ownerEntityUrl,
1186
+ manifestKey,
1187
+ sharedStateId,
1188
+ })
1189
+ .onConflictDoUpdate({
1190
+ target: [
1191
+ sharedStateLinks.tenantId,
1192
+ sharedStateLinks.ownerEntityUrl,
1193
+ sharedStateLinks.manifestKey,
1194
+ ],
1195
+ set: {
1196
+ sharedStateId,
1197
+ updatedAt: new Date(),
1198
+ },
1199
+ })
1200
+ }
1201
+
1202
+ async listSharedStateLinkedEntityUrls(
1203
+ sharedStateId: string
1204
+ ): Promise<Array<string>> {
1205
+ const rows = await this.db
1206
+ .selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl })
1207
+ .from(sharedStateLinks)
1208
+ .where(
1209
+ and(
1210
+ eq(sharedStateLinks.tenantId, this.tenantId),
1211
+ eq(sharedStateLinks.sharedStateId, sharedStateId)
1212
+ )
1213
+ )
1214
+ return rows.map((row) => row.ownerEntityUrl)
1215
+ }
1216
+
1217
+ async pruneExpiredPermissionGrants(
1218
+ now: Date = new Date(),
1219
+ options: { force?: boolean } = {}
1220
+ ): Promise<void> {
1221
+ if (this.permissionPrunePromise) return await this.permissionPrunePromise
1222
+
1223
+ const startedAt = Date.now()
1224
+ if (
1225
+ !options.force &&
1226
+ startedAt - this.lastPermissionPruneStartedAt <
1227
+ PERMISSION_PRUNE_INTERVAL_MS
1228
+ ) {
1229
+ return
1230
+ }
1231
+
1232
+ this.lastPermissionPruneStartedAt = startedAt
1233
+ const promise = this.pruneExpiredPermissionGrantsNow(now)
1234
+ this.permissionPrunePromise = promise
1235
+ try {
1236
+ await promise
1237
+ } catch (error) {
1238
+ this.lastPermissionPruneStartedAt = 0
1239
+ throw error
1240
+ } finally {
1241
+ if (this.permissionPrunePromise === promise) {
1242
+ this.permissionPrunePromise = null
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ private async pruneExpiredPermissionGrantsNow(now: Date): Promise<void> {
1248
+ await this.db.transaction(async (tx) => {
1249
+ const expiredEntityGrantIds = await tx
1250
+ .select({ id: entityPermissionGrants.id })
1251
+ .from(entityPermissionGrants)
1252
+ .where(
1253
+ and(
1254
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1255
+ sql`${entityPermissionGrants.expiresAt} IS NOT NULL`,
1256
+ lt(entityPermissionGrants.expiresAt, now)
1257
+ )
1258
+ )
1259
+ const ids = expiredEntityGrantIds.map((row) => row.id)
1260
+ if (ids.length > 0) {
1261
+ await tx
1262
+ .delete(entityEffectivePermissions)
1263
+ .where(
1264
+ and(
1265
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1266
+ inArray(entityEffectivePermissions.sourceGrantId, ids)
1267
+ )
1268
+ )
1269
+ await tx
1270
+ .delete(entityPermissionGrants)
1271
+ .where(
1272
+ and(
1273
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1274
+ inArray(entityPermissionGrants.id, ids)
1275
+ )
1276
+ )
1277
+ }
1278
+
1279
+ await tx
1280
+ .delete(entityEffectivePermissions)
1281
+ .where(
1282
+ and(
1283
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1284
+ sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`,
1285
+ lt(entityEffectivePermissions.expiresAt, now)
1286
+ )
1287
+ )
1288
+ await tx
1289
+ .delete(entityTypePermissionGrants)
1290
+ .where(
1291
+ and(
1292
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1293
+ sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`,
1294
+ lt(entityTypePermissionGrants.expiresAt, now)
1295
+ )
1296
+ )
1297
+ })
1298
+ }
1299
+
1300
+ private async materializeEntityPermissionGrant(
1301
+ tx: RegistryTransaction,
1302
+ grant: typeof entityPermissionGrants.$inferSelect
1303
+ ): Promise<void> {
1304
+ await tx
1305
+ .delete(entityEffectivePermissions)
1306
+ .where(
1307
+ and(
1308
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1309
+ eq(entityEffectivePermissions.sourceGrantId, grant.id)
1310
+ )
1311
+ )
1312
+
1313
+ if (grant.propagation === `descendants`) {
1314
+ await tx.execute(sql`
1315
+ INSERT INTO ${entityEffectivePermissions} (
1316
+ tenant_id,
1317
+ entity_url,
1318
+ source_entity_url,
1319
+ source_grant_id,
1320
+ permission,
1321
+ subject_kind,
1322
+ subject_value,
1323
+ expires_at
1324
+ )
1325
+ SELECT
1326
+ ${this.tenantId},
1327
+ descendant_url,
1328
+ ${grant.entityUrl},
1329
+ ${grant.id},
1330
+ ${grant.permission},
1331
+ ${grant.subjectKind},
1332
+ ${grant.subjectValue},
1333
+ ${grant.expiresAt}
1334
+ FROM ${entityLineage}
1335
+ WHERE tenant_id = ${this.tenantId}
1336
+ AND ancestor_url = ${grant.entityUrl}
1337
+ ON CONFLICT DO NOTHING
1338
+ `)
1339
+ return
1340
+ }
1341
+
1342
+ await tx
1343
+ .insert(entityEffectivePermissions)
1344
+ .values({
1345
+ tenantId: this.tenantId,
1346
+ entityUrl: grant.entityUrl,
1347
+ sourceEntityUrl: grant.entityUrl,
1348
+ sourceGrantId: grant.id,
1349
+ permission: grant.permission,
1350
+ subjectKind: grant.subjectKind,
1351
+ subjectValue: grant.subjectValue,
1352
+ expiresAt: grant.expiresAt,
1353
+ })
1354
+ .onConflictDoNothing()
1355
+ }
1356
+
793
1357
  async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
794
1358
  const whereClause = isTerminalEntityStatus(status)
795
1359
  ? this.entityWhere(entityUrl)
@@ -953,6 +1517,8 @@ export class PostgresRegistry {
953
1517
  sourceRef: string
954
1518
  tags: EntityTags
955
1519
  streamUrl: string
1520
+ principalUrl: string
1521
+ principalKind: string
956
1522
  }): Promise<EntityBridgeRow> {
957
1523
  await this.db
958
1524
  .insert(entityBridges)
@@ -961,6 +1527,8 @@ export class PostgresRegistry {
961
1527
  sourceRef: row.sourceRef,
962
1528
  tags: normalizeTags(row.tags),
963
1529
  streamUrl: row.streamUrl,
1530
+ principalUrl: row.principalUrl,
1531
+ principalKind: row.principalKind,
964
1532
  })
965
1533
  .onConflictDoNothing()
966
1534
 
@@ -1271,6 +1839,40 @@ export class PostgresRegistry {
1271
1839
  }
1272
1840
  }
1273
1841
 
1842
+ private rowToEntityTypePermissionGrant(
1843
+ row: typeof entityTypePermissionGrants.$inferSelect
1844
+ ): EntityTypePermissionGrant {
1845
+ return {
1846
+ id: row.id,
1847
+ entity_type: row.entityType,
1848
+ permission: row.permission as EntityTypePermission,
1849
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1850
+ subject_value: row.subjectValue,
1851
+ created_by: row.createdBy ?? undefined,
1852
+ expires_at: row.expiresAt?.toISOString(),
1853
+ created_at: row.createdAt.toISOString(),
1854
+ updated_at: row.updatedAt.toISOString(),
1855
+ }
1856
+ }
1857
+
1858
+ private rowToEntityPermissionGrant(
1859
+ row: typeof entityPermissionGrants.$inferSelect
1860
+ ): EntityPermissionGrant {
1861
+ return {
1862
+ id: row.id,
1863
+ entity_url: row.entityUrl,
1864
+ permission: row.permission as EntityPermission,
1865
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1866
+ subject_value: row.subjectValue,
1867
+ propagation: row.propagation as EntityPermissionPropagation,
1868
+ copy_to_children: row.copyToChildren,
1869
+ created_by: row.createdBy ?? undefined,
1870
+ expires_at: row.expiresAt?.toISOString(),
1871
+ created_at: row.createdAt.toISOString(),
1872
+ updated_at: row.updatedAt.toISOString(),
1873
+ }
1874
+ }
1875
+
1274
1876
  private rowToEntity(row: typeof entities.$inferSelect): ElectricAgentsEntity {
1275
1877
  return {
1276
1878
  url: row.url,
@@ -1278,7 +1880,6 @@ export class PostgresRegistry {
1278
1880
  status: assertEntityStatus(row.status),
1279
1881
  streams: {
1280
1882
  main: `${row.url}/main`,
1281
- error: `${row.url}/error`,
1282
1883
  },
1283
1884
  subscription_id: row.subscriptionId,
1284
1885
  dispatch_policy:
@@ -1311,6 +1912,8 @@ export class PostgresRegistry {
1311
1912
  sourceRef: row.sourceRef,
1312
1913
  tags: (row.tags as EntityTags | null | undefined) ?? {},
1313
1914
  streamUrl: row.streamUrl,
1915
+ principalUrl: row.principalUrl ?? undefined,
1916
+ principalKind: row.principalKind ?? undefined,
1314
1917
  shapeHandle: row.shapeHandle ?? undefined,
1315
1918
  shapeOffset: row.shapeOffset ?? undefined,
1316
1919
  lastObserverActivityAt: row.lastObserverActivityAt,