@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.
- package/dist/entrypoint.js +1176 -232
- package/dist/index.cjs +1179 -226
- package/dist/index.d.cts +1146 -167
- package/dist/index.d.ts +1146 -167
- package/dist/index.js +1181 -228
- package/drizzle/0011_entity_permissions.sql +100 -0
- package/drizzle/0012_horton_user_manage_permission.sql +25 -0
- package/drizzle/0013_worker_user_manage_permission.sql +25 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/db/schema.ts +198 -0
- package/src/electric-agents-types.ts +76 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +78 -60
- package/src/entity-projector.ts +76 -17
- package/src/entity-registry.ts +608 -5
- package/src/index.ts +11 -0
- package/src/permissions.ts +239 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +125 -4
- package/src/routing/electric-proxy-router.ts +4 -0
- package/src/routing/entities-router.ts +344 -20
- package/src/routing/entity-types-router.ts +244 -15
- package/src/routing/hooks.ts +1 -0
- package/src/routing/observations-router.ts +2 -1
- package/src/runtime.ts +34 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/utils/server-utils.ts +191 -11
- package/src/wake-registry.ts +8 -0
package/src/entity-manager.ts
CHANGED
|
@@ -73,7 +73,6 @@ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
|
73
73
|
import type { Principal } from './principal.js'
|
|
74
74
|
|
|
75
75
|
type SpawnPersistResult = [
|
|
76
|
-
PromiseSettledResult<void>,
|
|
77
76
|
PromiseSettledResult<void>,
|
|
78
77
|
PromiseSettledResult<number>,
|
|
79
78
|
]
|
|
@@ -163,14 +162,14 @@ type ForkSubtreeOptions = {
|
|
|
163
162
|
rootInstanceId?: string
|
|
164
163
|
waitTimeoutMs?: number
|
|
165
164
|
waitPollMs?: number
|
|
165
|
+
createdBy?: string
|
|
166
166
|
/**
|
|
167
167
|
* Optional anchor pointing at an event on the source root's `main` stream.
|
|
168
168
|
* When set: only events at or before the pointer are kept on the root's
|
|
169
169
|
* forked `main`, and the root's manifest is filtered so that descendants
|
|
170
170
|
* spawned after the pointer are dropped from the fork (their now-orphan
|
|
171
171
|
* subtrees are not forked). The pointer applies only to the root's
|
|
172
|
-
* `main` stream
|
|
173
|
-
* regardless.
|
|
172
|
+
* `main` stream; shared-state streams clone at HEAD regardless.
|
|
174
173
|
*/
|
|
175
174
|
forkPointer?: EventPointer
|
|
176
175
|
}
|
|
@@ -497,7 +496,10 @@ export class EntityManager {
|
|
|
497
496
|
|
|
498
497
|
async ensurePrincipal(principal: Principal): Promise<ElectricAgentsEntity> {
|
|
499
498
|
const existing = await this.registry.getEntity(principal.url)
|
|
500
|
-
if (existing)
|
|
499
|
+
if (existing) {
|
|
500
|
+
await this.ensureUserPrincipal(principal)
|
|
501
|
+
return existing
|
|
502
|
+
}
|
|
501
503
|
await this.ensurePrincipalEntityType()
|
|
502
504
|
try {
|
|
503
505
|
const entity = await this.spawn(`principal`, {
|
|
@@ -522,6 +524,7 @@ export class EntityManager {
|
|
|
522
524
|
},
|
|
523
525
|
})
|
|
524
526
|
)
|
|
527
|
+
await this.ensureUserPrincipal(principal)
|
|
525
528
|
return entity
|
|
526
529
|
} catch (error) {
|
|
527
530
|
if (
|
|
@@ -529,12 +532,21 @@ export class EntityManager {
|
|
|
529
532
|
error.code === ErrCodeDuplicateURL
|
|
530
533
|
) {
|
|
531
534
|
const raced = await this.registry.getEntity(principal.url)
|
|
532
|
-
if (raced)
|
|
535
|
+
if (raced) {
|
|
536
|
+
await this.ensureUserPrincipal(principal)
|
|
537
|
+
return raced
|
|
538
|
+
}
|
|
533
539
|
}
|
|
534
540
|
throw error
|
|
535
541
|
}
|
|
536
542
|
}
|
|
537
543
|
|
|
544
|
+
private async ensureUserPrincipal(principal: Principal): Promise<void> {
|
|
545
|
+
if (principal.kind === `user`) {
|
|
546
|
+
await this.registry.ensureUserForPrincipal(principal)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
538
550
|
// ==========================================================================
|
|
539
551
|
// Spawn
|
|
540
552
|
// ==========================================================================
|
|
@@ -624,7 +636,6 @@ export class EntityManager {
|
|
|
624
636
|
? principalUrl(instanceId)
|
|
625
637
|
: `/${typeName}/${instanceId}`
|
|
626
638
|
const mainPath = `${entityURL}/main`
|
|
627
|
-
const errorPath = `${entityURL}/error`
|
|
628
639
|
|
|
629
640
|
const subscriptionId = `${typeName}-handler`
|
|
630
641
|
|
|
@@ -676,7 +687,6 @@ export class EntityManager {
|
|
|
676
687
|
url: entityURL,
|
|
677
688
|
streams: {
|
|
678
689
|
main: mainPath,
|
|
679
|
-
error: errorPath,
|
|
680
690
|
},
|
|
681
691
|
subscription_id: subscriptionId,
|
|
682
692
|
dispatch_policy: dispatchPolicy,
|
|
@@ -745,8 +755,8 @@ export class EntityManager {
|
|
|
745
755
|
const queueEnterT0 = performance.now()
|
|
746
756
|
const queueWaiting = this.spawnPersistQueue.length()
|
|
747
757
|
const queueRunning = this.spawnPersistQueue.running()
|
|
748
|
-
const [mainStreamResult,
|
|
749
|
-
|
|
758
|
+
const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(
|
|
759
|
+
async () => {
|
|
750
760
|
// Create entity first so it's visible in the DB before stream
|
|
751
761
|
// creation can trigger webhooks that look up the entity.
|
|
752
762
|
let entityTxid: number
|
|
@@ -756,41 +766,34 @@ export class EntityManager {
|
|
|
756
766
|
)
|
|
757
767
|
} catch (err) {
|
|
758
768
|
return [
|
|
759
|
-
{ status: `fulfilled`, value: undefined },
|
|
760
769
|
{ status: `fulfilled`, value: undefined },
|
|
761
770
|
{ status: `rejected`, reason: err },
|
|
762
771
|
] as SpawnPersistResult
|
|
763
772
|
}
|
|
764
773
|
|
|
765
|
-
const [mainStreamResult
|
|
774
|
+
const [mainStreamResult] = await Promise.allSettled([
|
|
766
775
|
this.streamClient.create(mainPath, {
|
|
767
776
|
contentType,
|
|
768
777
|
body: initialBody,
|
|
769
778
|
}),
|
|
770
|
-
this.streamClient.create(errorPath, { contentType }),
|
|
771
779
|
])
|
|
772
780
|
|
|
773
781
|
return [
|
|
774
782
|
mainStreamResult,
|
|
775
|
-
errorStreamResult,
|
|
776
783
|
{ status: `fulfilled`, value: entityTxid },
|
|
777
784
|
] as SpawnPersistResult
|
|
778
|
-
}
|
|
785
|
+
}
|
|
786
|
+
)
|
|
779
787
|
const parallelMs = +(performance.now() - queueEnterT0).toFixed(2)
|
|
780
788
|
|
|
781
789
|
if (
|
|
782
790
|
mainStreamResult.status === `rejected` ||
|
|
783
|
-
errorStreamResult.status === `rejected` ||
|
|
784
791
|
entityResult.status === `rejected`
|
|
785
792
|
) {
|
|
786
793
|
const entityReason =
|
|
787
794
|
entityResult.status === `rejected` ? entityResult.reason : null
|
|
788
795
|
const streamReason =
|
|
789
|
-
mainStreamResult.status === `rejected`
|
|
790
|
-
? mainStreamResult.reason
|
|
791
|
-
: errorStreamResult.status === `rejected`
|
|
792
|
-
? errorStreamResult.reason
|
|
793
|
-
: null
|
|
796
|
+
mainStreamResult.status === `rejected` ? mainStreamResult.reason : null
|
|
794
797
|
const isDuplicate = entityReason instanceof EntityAlreadyExistsError
|
|
795
798
|
const isStreamConflict =
|
|
796
799
|
!!streamReason &&
|
|
@@ -805,9 +808,6 @@ export class EntityManager {
|
|
|
805
808
|
if (mainStreamResult.status === `fulfilled`) {
|
|
806
809
|
rollbacks.push(this.streamClient.delete(mainPath))
|
|
807
810
|
}
|
|
808
|
-
if (errorStreamResult.status === `fulfilled`) {
|
|
809
|
-
rollbacks.push(this.streamClient.delete(errorPath))
|
|
810
|
-
}
|
|
811
811
|
if (entityResult.status === `fulfilled`) {
|
|
812
812
|
rollbacks.push(this.registry.deleteEntity(entityURL))
|
|
813
813
|
}
|
|
@@ -834,9 +834,7 @@ export class EntityManager {
|
|
|
834
834
|
const failure =
|
|
835
835
|
mainStreamResult.status === `rejected`
|
|
836
836
|
? mainStreamResult.reason
|
|
837
|
-
:
|
|
838
|
-
? errorStreamResult.reason
|
|
839
|
-
: (entityResult as PromiseRejectedResult).reason
|
|
837
|
+
: (entityResult as PromiseRejectedResult).reason
|
|
840
838
|
if (failure instanceof Error) throw failure
|
|
841
839
|
throw new ElectricAgentsError(
|
|
842
840
|
`SPAWN_FAILED`,
|
|
@@ -1045,7 +1043,8 @@ export class EntityManager {
|
|
|
1045
1043
|
const entityPlans = this.buildForkEntityPlans(
|
|
1046
1044
|
effectiveSubtree,
|
|
1047
1045
|
entityUrlMap,
|
|
1048
|
-
stringMap
|
|
1046
|
+
stringMap,
|
|
1047
|
+
opts.createdBy
|
|
1049
1048
|
)
|
|
1050
1049
|
|
|
1051
1050
|
this.addForkLocks(
|
|
@@ -1077,13 +1076,6 @@ export class EntityManager {
|
|
|
1077
1076
|
: undefined
|
|
1078
1077
|
)
|
|
1079
1078
|
createdStreams.push(plan.fork.streams.main)
|
|
1080
|
-
// `error` always clones at HEAD — no canonical mapping
|
|
1081
|
-
// between main-offset and error-offset.
|
|
1082
|
-
await this.streamClient.fork(
|
|
1083
|
-
plan.fork.streams.error,
|
|
1084
|
-
plan.source.streams.error
|
|
1085
|
-
)
|
|
1086
|
-
createdStreams.push(plan.fork.streams.error)
|
|
1087
1079
|
}
|
|
1088
1080
|
|
|
1089
1081
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
@@ -1711,7 +1703,6 @@ export class EntityManager {
|
|
|
1711
1703
|
for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
1712
1704
|
stringMap.set(sourceUrl, forkUrl)
|
|
1713
1705
|
stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`)
|
|
1714
|
-
stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`)
|
|
1715
1706
|
}
|
|
1716
1707
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
1717
1708
|
stringMap.set(sourceId, forkId)
|
|
@@ -1726,7 +1717,8 @@ export class EntityManager {
|
|
|
1726
1717
|
private buildForkEntityPlans(
|
|
1727
1718
|
entitiesToFork: Array<ElectricAgentsEntity>,
|
|
1728
1719
|
entityUrlMap: Map<string, string>,
|
|
1729
|
-
stringMap: Map<string, string
|
|
1720
|
+
stringMap: Map<string, string>,
|
|
1721
|
+
createdBy?: string
|
|
1730
1722
|
): Array<ForkEntityPlan> {
|
|
1731
1723
|
const now = Date.now()
|
|
1732
1724
|
return entitiesToFork.map((source) => {
|
|
@@ -1750,12 +1742,12 @@ export class EntityManager {
|
|
|
1750
1742
|
status: `idle`,
|
|
1751
1743
|
streams: {
|
|
1752
1744
|
main: `${forkUrl}/main`,
|
|
1753
|
-
error: `${forkUrl}/error`,
|
|
1754
1745
|
},
|
|
1755
1746
|
subscription_id: `${type}-handler`,
|
|
1756
1747
|
write_token: randomUUID(),
|
|
1757
1748
|
spawn_args: spawnArgs,
|
|
1758
1749
|
parent,
|
|
1750
|
+
created_by: createdBy ?? source.created_by,
|
|
1759
1751
|
created_at: now,
|
|
1760
1752
|
updated_at: now,
|
|
1761
1753
|
}
|
|
@@ -2047,12 +2039,7 @@ export class EntityManager {
|
|
|
2047
2039
|
manifests: Map<string, Record<string, unknown>>
|
|
2048
2040
|
): Promise<void> {
|
|
2049
2041
|
for (const [manifestKey, manifest] of manifests) {
|
|
2050
|
-
await this.
|
|
2051
|
-
entityUrl,
|
|
2052
|
-
manifestKey,
|
|
2053
|
-
`upsert`,
|
|
2054
|
-
manifest
|
|
2055
|
-
)
|
|
2042
|
+
await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest)
|
|
2056
2043
|
|
|
2057
2044
|
const wake = buildManifestWakeRegistration(
|
|
2058
2045
|
entityUrl,
|
|
@@ -2125,6 +2112,7 @@ export class EntityManager {
|
|
|
2125
2112
|
{
|
|
2126
2113
|
entityUrl: targetUrl,
|
|
2127
2114
|
from: senderUrl,
|
|
2115
|
+
from_agent: senderUrl,
|
|
2128
2116
|
payload: manifest.payload,
|
|
2129
2117
|
key: `scheduled-${producerId}`,
|
|
2130
2118
|
type:
|
|
@@ -2191,7 +2179,7 @@ export class EntityManager {
|
|
|
2191
2179
|
`msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
2192
2180
|
|
|
2193
2181
|
const value: Record<string, unknown> = {
|
|
2194
|
-
from: req.from,
|
|
2182
|
+
from: req.from_principal ?? req.from,
|
|
2195
2183
|
payload: req.payload,
|
|
2196
2184
|
timestamp: now,
|
|
2197
2185
|
mode: req.mode ?? `immediate`,
|
|
@@ -2200,6 +2188,12 @@ export class EntityManager {
|
|
|
2200
2188
|
? `pending`
|
|
2201
2189
|
: `processed`,
|
|
2202
2190
|
}
|
|
2191
|
+
if (req.from_principal) {
|
|
2192
|
+
value.from_principal = req.from_principal
|
|
2193
|
+
}
|
|
2194
|
+
if (req.from_agent) {
|
|
2195
|
+
value.from_agent = req.from_agent
|
|
2196
|
+
}
|
|
2203
2197
|
if (req.type) {
|
|
2204
2198
|
value.message_type = req.type
|
|
2205
2199
|
}
|
|
@@ -2610,14 +2604,21 @@ export class EntityManager {
|
|
|
2610
2604
|
return updated
|
|
2611
2605
|
}
|
|
2612
2606
|
|
|
2613
|
-
async ensureEntitiesMembershipStream(
|
|
2607
|
+
async ensureEntitiesMembershipStream(
|
|
2608
|
+
tags: Record<string, string>,
|
|
2609
|
+
principal: { url: string; kind: string }
|
|
2610
|
+
): Promise<{
|
|
2614
2611
|
sourceRef: string
|
|
2615
2612
|
streamUrl: string
|
|
2616
2613
|
}> {
|
|
2617
2614
|
if (!this.entityBridgeManager) {
|
|
2618
2615
|
throw new Error(`Entity bridge manager not configured`)
|
|
2619
2616
|
}
|
|
2620
|
-
return this.entityBridgeManager.register(
|
|
2617
|
+
return this.entityBridgeManager.register(
|
|
2618
|
+
this.validateTags(tags),
|
|
2619
|
+
principal.url,
|
|
2620
|
+
principal.kind
|
|
2621
|
+
)
|
|
2621
2622
|
}
|
|
2622
2623
|
|
|
2623
2624
|
async writeManifestEntry(
|
|
@@ -2650,12 +2651,12 @@ export class EntityManager {
|
|
|
2650
2651
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, {
|
|
2651
2652
|
producerId: opts.producerId,
|
|
2652
2653
|
})
|
|
2653
|
-
await this.
|
|
2654
|
+
await this.syncManifestLinks(entityUrl, key, operation, value)
|
|
2654
2655
|
return
|
|
2655
2656
|
}
|
|
2656
2657
|
|
|
2657
2658
|
await this.streamClient.append(entity.streams.main, encoded)
|
|
2658
|
-
await this.
|
|
2659
|
+
await this.syncManifestLinks(entityUrl, key, operation, value)
|
|
2659
2660
|
}
|
|
2660
2661
|
|
|
2661
2662
|
async upsertCronSchedule(
|
|
@@ -2950,6 +2951,8 @@ export class EntityManager {
|
|
|
2950
2951
|
{
|
|
2951
2952
|
entityUrl,
|
|
2952
2953
|
from: req.from,
|
|
2954
|
+
from_principal: req.from_principal,
|
|
2955
|
+
from_agent: req.from_agent,
|
|
2953
2956
|
payload: req.payload,
|
|
2954
2957
|
key: req.key,
|
|
2955
2958
|
type: req.type,
|
|
@@ -3031,7 +3034,7 @@ export class EntityManager {
|
|
|
3031
3034
|
})
|
|
3032
3035
|
}
|
|
3033
3036
|
|
|
3034
|
-
private async
|
|
3037
|
+
private async syncManifestLinks(
|
|
3035
3038
|
entityUrl: string,
|
|
3036
3039
|
manifestKey: string,
|
|
3037
3040
|
operation: `insert` | `update` | `upsert` | `delete`,
|
|
@@ -3044,6 +3047,14 @@ export class EntityManager {
|
|
|
3044
3047
|
manifestKey,
|
|
3045
3048
|
sourceRef
|
|
3046
3049
|
)
|
|
3050
|
+
|
|
3051
|
+
const sharedStateId =
|
|
3052
|
+
operation === `delete` ? undefined : this.extractSharedStateId(value)
|
|
3053
|
+
await this.registry.replaceSharedStateLink(
|
|
3054
|
+
entityUrl,
|
|
3055
|
+
manifestKey,
|
|
3056
|
+
sharedStateId
|
|
3057
|
+
)
|
|
3047
3058
|
}
|
|
3048
3059
|
|
|
3049
3060
|
private extractEntitiesSourceRef(
|
|
@@ -3059,6 +3070,24 @@ export class EntityManager {
|
|
|
3059
3070
|
return undefined
|
|
3060
3071
|
}
|
|
3061
3072
|
|
|
3073
|
+
private extractSharedStateId(
|
|
3074
|
+
manifest?: Record<string, unknown>
|
|
3075
|
+
): string | undefined {
|
|
3076
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) {
|
|
3077
|
+
return manifest.id
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) {
|
|
3081
|
+
return undefined
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
if (typeof manifest.sourceRef === `string`) {
|
|
3085
|
+
return manifest.sourceRef
|
|
3086
|
+
}
|
|
3087
|
+
const config = isRecord(manifest.config) ? manifest.config : undefined
|
|
3088
|
+
return typeof config?.id === `string` ? config.id : undefined
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3062
3091
|
/**
|
|
3063
3092
|
* Read a child entity's stream and extract concatenated text deltas
|
|
3064
3093
|
* for a specific run, plus any error messages for that run.
|
|
@@ -3334,19 +3363,8 @@ export class EntityManager {
|
|
|
3334
3363
|
return
|
|
3335
3364
|
}
|
|
3336
3365
|
|
|
3337
|
-
const errorCloseEvent = {
|
|
3338
|
-
type: `signal`,
|
|
3339
|
-
key: signalEvent.key,
|
|
3340
|
-
value: signalEvent.value,
|
|
3341
|
-
headers: signalEvent.headers,
|
|
3342
|
-
}
|
|
3343
|
-
const errorSignalData = this.encodeChangeEvent(
|
|
3344
|
-
errorCloseEvent as unknown as Record<string, unknown>
|
|
3345
|
-
)
|
|
3346
|
-
|
|
3347
3366
|
for (const [streamPath, data] of [
|
|
3348
3367
|
[entity.streams.main, signalData],
|
|
3349
|
-
[entity.streams.error, errorSignalData],
|
|
3350
3368
|
] as const) {
|
|
3351
3369
|
try {
|
|
3352
3370
|
await this.streamClient.append(streamPath, data, { close: true })
|
package/src/entity-projector.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
assertTags,
|
|
4
4
|
buildTagsIndex,
|
|
5
5
|
getEntitiesStreamPath,
|
|
6
|
+
hashString,
|
|
6
7
|
normalizeTags,
|
|
7
8
|
sourceRefForTags,
|
|
8
9
|
} from '@electric-ax/agents-runtime'
|
|
@@ -15,6 +16,7 @@ import { PostgresRegistry } from './entity-registry.js'
|
|
|
15
16
|
import { electricUrlWithPath } from './utils/electric-url.js'
|
|
16
17
|
import { serverLog } from './utils/log.js'
|
|
17
18
|
import { isUnregisteredTenantError } from './tenant.js'
|
|
19
|
+
import { isBuiltInSystemPrincipalUrl } from './principal.js'
|
|
18
20
|
import type { DrizzleDB } from './db/index.js'
|
|
19
21
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
20
22
|
import type { EntityBridgeRow } from './entity-registry.js'
|
|
@@ -37,6 +39,7 @@ interface EntityShapeRow extends Row<unknown> {
|
|
|
37
39
|
type: string
|
|
38
40
|
status: `spawning` | `running` | `idle` | `stopped`
|
|
39
41
|
tags: EntityTags
|
|
42
|
+
created_by?: string | null
|
|
40
43
|
spawn_args?: Record<string, unknown> | null
|
|
41
44
|
sandbox?: { profile: string } | null
|
|
42
45
|
parent?: string | null
|
|
@@ -53,6 +56,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
53
56
|
`type`,
|
|
54
57
|
`status`,
|
|
55
58
|
`tags`,
|
|
59
|
+
`created_by`,
|
|
56
60
|
`spawn_args`,
|
|
57
61
|
`sandbox`,
|
|
58
62
|
`parent`,
|
|
@@ -89,6 +93,16 @@ function sourceRefFromStreamPath(streamPath: string): string | null {
|
|
|
89
93
|
return match?.[1] ?? null
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
function principalScopedSourceRef(
|
|
97
|
+
tagSourceRef: string,
|
|
98
|
+
principalUrl: string,
|
|
99
|
+
principalKind: string
|
|
100
|
+
): string {
|
|
101
|
+
return `${tagSourceRef}-${hashString(
|
|
102
|
+
JSON.stringify({ principalKind, principalUrl })
|
|
103
|
+
)}`
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
function sameMember(
|
|
93
107
|
left: EntityMembershipRow | undefined,
|
|
94
108
|
right: EntityMembershipRow
|
|
@@ -125,6 +139,9 @@ class ProjectedEntityBridge {
|
|
|
125
139
|
readonly sourceRef: string
|
|
126
140
|
readonly tags: EntityTags
|
|
127
141
|
readonly streamUrl: string
|
|
142
|
+
private readonly principalUrl?: string
|
|
143
|
+
private readonly principalKind?: string
|
|
144
|
+
private readonly permissionBypass: boolean
|
|
128
145
|
|
|
129
146
|
private currentMembers = new Map<string, EntityMembershipRow>()
|
|
130
147
|
private producer: IdempotentProducer | null = null
|
|
@@ -132,12 +149,16 @@ class ProjectedEntityBridge {
|
|
|
132
149
|
|
|
133
150
|
constructor(
|
|
134
151
|
row: EntityBridgeRow,
|
|
152
|
+
private registry: PostgresRegistry,
|
|
135
153
|
private streamClient: StreamClient
|
|
136
154
|
) {
|
|
137
155
|
this.tenantId = row.tenantId
|
|
138
156
|
this.sourceRef = row.sourceRef
|
|
139
157
|
this.tags = normalizeTags(row.tags)
|
|
140
158
|
this.streamUrl = row.streamUrl
|
|
159
|
+
this.principalUrl = row.principalUrl
|
|
160
|
+
this.principalKind = row.principalKind
|
|
161
|
+
this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl)
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
async start(initialEntities: Iterable<EntityShapeRow>): Promise<void> {
|
|
@@ -159,7 +180,7 @@ class ProjectedEntityBridge {
|
|
|
159
180
|
}
|
|
160
181
|
)
|
|
161
182
|
await this.loadCurrentMembers()
|
|
162
|
-
this.reconcile(initialEntities)
|
|
183
|
+
await this.reconcile(initialEntities)
|
|
163
184
|
}
|
|
164
185
|
|
|
165
186
|
async stop(): Promise<void> {
|
|
@@ -175,13 +196,14 @@ class ProjectedEntityBridge {
|
|
|
175
196
|
}
|
|
176
197
|
}
|
|
177
198
|
|
|
178
|
-
reconcile(entities: Iterable<EntityShapeRow>): void {
|
|
199
|
+
async reconcile(entities: Iterable<EntityShapeRow>): Promise<void> {
|
|
179
200
|
if (this.stopped) return
|
|
180
201
|
|
|
181
202
|
const staleMembers = new Map(this.currentMembers)
|
|
182
203
|
for (const entity of entities) {
|
|
183
204
|
if (entity.tenant_id !== this.tenantId) continue
|
|
184
205
|
if (!entityMatchesTags(entity, this.tags)) continue
|
|
206
|
+
if (!(await this.canReadEntity(entity))) continue
|
|
185
207
|
staleMembers.delete(entity.url)
|
|
186
208
|
this.upsertEntity(entity)
|
|
187
209
|
}
|
|
@@ -192,11 +214,14 @@ class ProjectedEntityBridge {
|
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
applyEntity(entity: EntityShapeRow): void {
|
|
217
|
+
async applyEntity(entity: EntityShapeRow): Promise<void> {
|
|
196
218
|
if (this.stopped) return
|
|
197
219
|
if (entity.tenant_id !== this.tenantId) return
|
|
198
220
|
|
|
199
|
-
if (
|
|
221
|
+
if (
|
|
222
|
+
!entityMatchesTags(entity, this.tags) ||
|
|
223
|
+
!(await this.canReadEntity(entity))
|
|
224
|
+
) {
|
|
200
225
|
const existing = this.currentMembers.get(entity.url)
|
|
201
226
|
if (!existing) return
|
|
202
227
|
this.append(`delete`, existing)
|
|
@@ -231,6 +256,16 @@ class ProjectedEntityBridge {
|
|
|
231
256
|
}
|
|
232
257
|
}
|
|
233
258
|
|
|
259
|
+
private async canReadEntity(entity: EntityShapeRow): Promise<boolean> {
|
|
260
|
+
if (this.permissionBypass) return true
|
|
261
|
+
if (!this.principalUrl || !this.principalKind) return false
|
|
262
|
+
if (entity.created_by === this.principalUrl) return true
|
|
263
|
+
return await this.registry.hasEntityPermission(entity.url, `read`, {
|
|
264
|
+
principalUrl: this.principalUrl,
|
|
265
|
+
principalKind: this.principalKind,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
234
269
|
private async ensureStream(): Promise<void> {
|
|
235
270
|
if (!(await this.streamClient.exists(this.streamUrl))) {
|
|
236
271
|
await this.streamClient.create(this.streamUrl, {
|
|
@@ -377,7 +412,9 @@ export class EntityProjector {
|
|
|
377
412
|
async register(
|
|
378
413
|
tenantId: string,
|
|
379
414
|
registry: PostgresRegistry,
|
|
380
|
-
tagsInput: unknown
|
|
415
|
+
tagsInput: unknown,
|
|
416
|
+
principalUrl: string,
|
|
417
|
+
principalKind: string
|
|
381
418
|
): Promise<{ sourceRef: string; streamUrl: string }> {
|
|
382
419
|
if (!this.electricUrl) {
|
|
383
420
|
throw new Error(
|
|
@@ -388,12 +425,18 @@ export class EntityProjector {
|
|
|
388
425
|
await this.start()
|
|
389
426
|
this.registries.set(tenantId, registry)
|
|
390
427
|
const tags = normalizeTags(assertTags(tagsInput))
|
|
391
|
-
const sourceRef =
|
|
428
|
+
const sourceRef = principalScopedSourceRef(
|
|
429
|
+
sourceRefForTags(tags),
|
|
430
|
+
principalUrl,
|
|
431
|
+
principalKind
|
|
432
|
+
)
|
|
392
433
|
const streamUrl = getEntitiesStreamPath(sourceRef)
|
|
393
434
|
const row = await registry.upsertEntityBridge({
|
|
394
435
|
sourceRef,
|
|
395
436
|
tags,
|
|
396
437
|
streamUrl,
|
|
438
|
+
principalUrl,
|
|
439
|
+
principalKind,
|
|
397
440
|
})
|
|
398
441
|
await registry.touchEntityBridge(sourceRef)
|
|
399
442
|
await this.ensureProjection(row)
|
|
@@ -436,8 +479,12 @@ export class EntityProjector {
|
|
|
436
479
|
}
|
|
437
480
|
}
|
|
438
481
|
|
|
439
|
-
async onEntityChanged(
|
|
440
|
-
|
|
482
|
+
async onEntityChanged(tenantId: string, entityUrl: string): Promise<void> {
|
|
483
|
+
const entity = this.entities.get(entityKey(tenantId, entityUrl))
|
|
484
|
+
if (!entity) return
|
|
485
|
+
for (const projection of this.projectionsForTenant(tenantId)) {
|
|
486
|
+
await projection.applyEntity(entity)
|
|
487
|
+
}
|
|
441
488
|
}
|
|
442
489
|
|
|
443
490
|
async loadTenantBridges(
|
|
@@ -523,18 +570,20 @@ export class EntityProjector {
|
|
|
523
570
|
}
|
|
524
571
|
if (message.headers.control === `up-to-date`) {
|
|
525
572
|
this.upToDate = true
|
|
526
|
-
this.reconcileAll()
|
|
573
|
+
await this.reconcileAll()
|
|
527
574
|
this.readyResolve?.()
|
|
528
575
|
}
|
|
529
576
|
continue
|
|
530
577
|
}
|
|
531
578
|
|
|
532
579
|
if (!isChangeMessage(message)) continue
|
|
533
|
-
this.applyChangeMessage(message)
|
|
580
|
+
await this.applyChangeMessage(message)
|
|
534
581
|
}
|
|
535
582
|
}
|
|
536
583
|
|
|
537
|
-
private applyChangeMessage(
|
|
584
|
+
private async applyChangeMessage(
|
|
585
|
+
message: ChangeMessage<EntityShapeRow>
|
|
586
|
+
): Promise<void> {
|
|
538
587
|
const entity = message.value
|
|
539
588
|
const key = entityKey(entity.tenant_id, entity.url)
|
|
540
589
|
if (message.headers.operation === `delete`) {
|
|
@@ -550,7 +599,7 @@ export class EntityProjector {
|
|
|
550
599
|
this.entities.set(key, entity)
|
|
551
600
|
if (this.upToDate) {
|
|
552
601
|
for (const projection of this.projectionsForTenant(entity.tenant_id)) {
|
|
553
|
-
projection.applyEntity(entity)
|
|
602
|
+
await projection.applyEntity(entity)
|
|
554
603
|
}
|
|
555
604
|
}
|
|
556
605
|
}
|
|
@@ -642,7 +691,11 @@ export class EntityProjector {
|
|
|
642
691
|
}
|
|
643
692
|
throw error
|
|
644
693
|
}
|
|
645
|
-
const projection = new ProjectedEntityBridge(
|
|
694
|
+
const projection = new ProjectedEntityBridge(
|
|
695
|
+
row,
|
|
696
|
+
this.registryForTenant(row.tenantId),
|
|
697
|
+
streamClient
|
|
698
|
+
)
|
|
646
699
|
await projection.start(this.entitiesForTenant(row.tenantId))
|
|
647
700
|
this.projections.set(key, projection)
|
|
648
701
|
})().finally(() => {
|
|
@@ -665,9 +718,9 @@ export class EntityProjector {
|
|
|
665
718
|
)
|
|
666
719
|
}
|
|
667
720
|
|
|
668
|
-
private reconcileAll(): void {
|
|
721
|
+
private async reconcileAll(): Promise<void> {
|
|
669
722
|
for (const projection of this.projections.values()) {
|
|
670
|
-
projection.reconcile(this.entitiesForTenant(projection.tenantId))
|
|
723
|
+
await projection.reconcile(this.entitiesForTenant(projection.tenantId))
|
|
671
724
|
}
|
|
672
725
|
}
|
|
673
726
|
|
|
@@ -733,14 +786,20 @@ export class EntityProjectorTenantFacade implements EntityBridgeCoordinator {
|
|
|
733
786
|
|
|
734
787
|
async stop(): Promise<void> {}
|
|
735
788
|
|
|
736
|
-
async register(
|
|
789
|
+
async register(
|
|
790
|
+
tagsInput: unknown,
|
|
791
|
+
principalUrl: string,
|
|
792
|
+
principalKind: string
|
|
793
|
+
): Promise<{
|
|
737
794
|
sourceRef: string
|
|
738
795
|
streamUrl: string
|
|
739
796
|
}> {
|
|
740
797
|
return await this.projector.register(
|
|
741
798
|
this.tenantId,
|
|
742
799
|
this.registry,
|
|
743
|
-
tagsInput
|
|
800
|
+
tagsInput,
|
|
801
|
+
principalUrl,
|
|
802
|
+
principalKind
|
|
744
803
|
)
|
|
745
804
|
}
|
|
746
805
|
|