jinaga-server 3.5.4 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +5 -2
- package/.github/workflows/upgrade-jinaga.yml +51 -0
- package/CLAUDE.md +77 -0
- package/dist/authorization/authorization-keystore.d.ts +27 -1
- package/dist/authorization/authorization-keystore.js +52 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/http/router.d.ts +4 -1
- package/dist/http/router.js +157 -14
- package/dist/http/router.js.map +1 -1
- package/dist/jinaga-server.js +18 -1
- package/dist/jinaga-server.js.map +1 -1
- package/dist/memory/memory-keystore.js +1 -1
- package/dist/memory/memory-keystore.js.map +1 -1
- package/dist/postgres/query-description.d.ts +1 -0
- package/dist/postgres/query-description.js +34 -3
- package/dist/postgres/query-description.js.map +1 -1
- package/dist/postgres/specification-sql.js +7 -1
- package/dist/postgres/specification-sql.js.map +1 -1
- package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
- package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
- package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
- package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
- package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +3 -2
- package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
- package/integration-test/jinaga-test/subscription-helpers.js +60 -0
- package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
- package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
- package/package.json +2 -2
- package/src/authorization/authorization-keystore.ts +87 -5
- package/src/http/router.ts +174 -21
- package/src/jinaga-server.ts +36 -3
- package/src/memory/memory-keystore.ts +1 -1
- package/src/postgres/query-description.ts +66 -11
- package/src/postgres/specification-sql.ts +7 -1
- package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/lateAuthRecoverySpec.ts +245 -0
- package/test/http/streamFeedAnchorSpec.ts +169 -0
|
@@ -7,14 +7,17 @@ on:
|
|
|
7
7
|
jobs:
|
|
8
8
|
build:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
10
13
|
steps:
|
|
11
14
|
- uses: actions/checkout@v4
|
|
12
|
-
# Setup .npmrc file to publish to npm
|
|
13
15
|
- uses: actions/setup-node@v4
|
|
14
16
|
with:
|
|
15
17
|
node-version: '20.x'
|
|
16
18
|
registry-url: 'https://registry.npmjs.org'
|
|
19
|
+
- run: npm install -g npm@latest
|
|
17
20
|
- run: npm install
|
|
18
21
|
- run: npm publish
|
|
19
22
|
env:
|
|
20
|
-
NODE_AUTH_TOKEN:
|
|
23
|
+
NODE_AUTH_TOKEN: ''
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Upgrade jinaga
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '0 6 * * *' # daily at 06:00 UTC
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
upgrade:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: '20.x'
|
|
22
|
+
|
|
23
|
+
- name: Get versions
|
|
24
|
+
id: versions
|
|
25
|
+
run: |
|
|
26
|
+
CURRENT=$(node -p "require('./package.json').dependencies.jinaga.replace(/[\^~]/, '')")
|
|
27
|
+
LATEST=$(npm view jinaga dist-tags.latest)
|
|
28
|
+
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
|
|
29
|
+
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
|
|
30
|
+
|
|
31
|
+
- name: Update root
|
|
32
|
+
if: steps.versions.outputs.current != steps.versions.outputs.latest
|
|
33
|
+
run: npm install jinaga@${{ steps.versions.outputs.latest }}
|
|
34
|
+
|
|
35
|
+
- name: Update integration-test
|
|
36
|
+
if: steps.versions.outputs.current != steps.versions.outputs.latest
|
|
37
|
+
working-directory: integration-test/jinaga-test
|
|
38
|
+
run: npm install jinaga@${{ steps.versions.outputs.latest }}
|
|
39
|
+
|
|
40
|
+
- name: Open PR
|
|
41
|
+
if: steps.versions.outputs.current != steps.versions.outputs.latest
|
|
42
|
+
uses: peter-evans/create-pull-request@v7
|
|
43
|
+
with:
|
|
44
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
45
|
+
branch: upgrade/jinaga-${{ steps.versions.outputs.latest }}
|
|
46
|
+
commit-message: "Upgraded Jinaga to ${{ steps.versions.outputs.latest }}"
|
|
47
|
+
title: "Upgraded Jinaga to ${{ steps.versions.outputs.latest }}"
|
|
48
|
+
body: |
|
|
49
|
+
Automated upgrade of `jinaga` from `${{ steps.versions.outputs.current }}` to `${{ steps.versions.outputs.latest }}`.
|
|
50
|
+
labels: dependencies
|
|
51
|
+
delete-branch: true
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run build # Compile TypeScript to dist/
|
|
9
|
+
npm test # Type-check + run Jest tests
|
|
10
|
+
npm run test:watch # Jest in watch mode
|
|
11
|
+
npm run clean # Remove dist/ and integration-test artifacts
|
|
12
|
+
npm run integration # Full build + integration tests (requires Postgres)
|
|
13
|
+
npm run debug # Run debug.ts entry point (configure DB in src/debug.ts first)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run a single test file:
|
|
17
|
+
```bash
|
|
18
|
+
npx jest test/postgres/postgresReadSpec.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Run tests matching a pattern:
|
|
22
|
+
```bash
|
|
23
|
+
npx jest --testNamePattern="should read"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
Jinaga Server is a middle-tier backend that syncs immutable facts between clients and PostgreSQL. Facts are append-only records (never mutated), signed by user/device identities, and queried via declarative Specifications rather than SQL.
|
|
29
|
+
|
|
30
|
+
### Layers (top to bottom)
|
|
31
|
+
|
|
32
|
+
**HTTP** (`src/http/router.ts`) — Express router with three endpoints:
|
|
33
|
+
- `POST /jinaga/write` — accept FactEnvelopes from clients
|
|
34
|
+
- `POST /jinaga/read` — query facts by Specification
|
|
35
|
+
- `POST /jinaga/feed` — long-lived streaming subscription (`application/x-jinaga-feed-stream`)
|
|
36
|
+
|
|
37
|
+
**Authentication** (`src/authentication/`) — Associates each request with a user/device identity. `AuthenticationDevice` is for server-local operations; `AuthenticationSession` is for per-request user context.
|
|
38
|
+
|
|
39
|
+
**Authorization** (`src/authorization/authorization-keystore.ts`) — Enforces who can write facts and who can receive them, using `AuthorizationEngine` and `DistributionEngine` from the `jinaga` core library.
|
|
40
|
+
|
|
41
|
+
**Core** — `FactManager` from the `jinaga` library handles fact storage coordination. This server implements the `Storage` and `Keystore` interfaces that `FactManager` depends on.
|
|
42
|
+
|
|
43
|
+
**Storage** (`src/postgres/`, `src/memory/`) — Two implementations:
|
|
44
|
+
- `PostgresStore` + `PostgresKeystore` — production persistence in PostgreSQL
|
|
45
|
+
- `MemoryKeystore` (in-memory) — used in tests and development
|
|
46
|
+
|
|
47
|
+
### Assembly
|
|
48
|
+
|
|
49
|
+
`JinagaServer.create(config)` in `src/jinaga-server.ts` wires everything together via dependency injection. It reads the config to determine which storage strategy, fork strategy (pass-through, transient, or persistent for upstream replication), and authorization/distribution rules to use.
|
|
50
|
+
|
|
51
|
+
### Specification → SQL
|
|
52
|
+
|
|
53
|
+
The most complex translation in the codebase is in `src/postgres/specification-sql.ts`, which converts a `Specification` object (from the `jinaga` library's query DSL) into SQL JOIN queries. Related: `src/postgres/specification-result-sql.ts` for result projection and `src/postgres/purge-sql.ts` for deletions.
|
|
54
|
+
|
|
55
|
+
### Feed streaming
|
|
56
|
+
|
|
57
|
+
`src/http/stream.ts` manages long-lived HTTP connections. When new facts arrive that match a subscription, they're pushed to all connected clients via the queue-based event system.
|
|
58
|
+
|
|
59
|
+
### Fork strategies
|
|
60
|
+
|
|
61
|
+
Forks determine how facts propagate upstream:
|
|
62
|
+
- `PassThroughFork` — no upstream, single-server
|
|
63
|
+
- `TransientFork` — in-memory queue (dev/test)
|
|
64
|
+
- `PersistentFork` — `postgres-queue.ts` backed queue for reliable upstream replication to other Jinaga servers
|
|
65
|
+
|
|
66
|
+
## Key interfaces
|
|
67
|
+
|
|
68
|
+
- `Storage` (from `jinaga`) — `save()`, `read()`, `feed()`, `remove()`
|
|
69
|
+
- `Keystore` (`src/keystore.ts`) — `getOrCreateUserFact()`, `signFacts()`, etc.
|
|
70
|
+
|
|
71
|
+
## Database schema
|
|
72
|
+
|
|
73
|
+
`setup.sql` initializes the schema. Key tables: `fact`, `fact_type`, `role`, `ancestor`, `signature`, `public_key`, `user`, `device`. Facts are immutable once written. The `ancestor` table tracks relationships between facts (predecessor/successor edges).
|
|
74
|
+
|
|
75
|
+
## Testing
|
|
76
|
+
|
|
77
|
+
Tests live in `test/` matching pattern `**/test/**/*Spec.ts`. Tests use the in-memory keystore and often the actual `PostgresStore` against a real database for integration-level unit tests. See `test/models/blog.ts` for the canonical test data model.
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { Authorization, AuthorizationRules, DistributionRules, FactEnvelope, FactFeed, FactManager, FactReference, ReferencesByName, Specification, Storage, UserIdentity } from "jinaga";
|
|
2
|
+
export interface DistributionIntersectionBranch {
|
|
3
|
+
start: FactReference[];
|
|
4
|
+
specification: Specification;
|
|
5
|
+
}
|
|
6
|
+
export type DistributionBranchesResult = {
|
|
7
|
+
type: "success";
|
|
8
|
+
branches: DistributionIntersectionBranch[];
|
|
9
|
+
} | {
|
|
10
|
+
type: "denied";
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
export type FeedResult = {
|
|
14
|
+
type: "success";
|
|
15
|
+
feed: FactFeed;
|
|
16
|
+
} | {
|
|
17
|
+
type: "denied";
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
export interface SubscriptionAuthorizer {
|
|
21
|
+
verifyDistributionOrIntersect(userIdentity: UserIdentity | null, specification: Specification, namedStart: ReferencesByName): Promise<DistributionBranchesResult>;
|
|
22
|
+
feedPreVerified(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FactFeed>;
|
|
23
|
+
feedWithDistribution(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FeedResult>;
|
|
24
|
+
}
|
|
2
25
|
import { Keystore } from "../keystore";
|
|
3
|
-
export declare class AuthorizationKeystore implements Authorization {
|
|
26
|
+
export declare class AuthorizationKeystore implements Authorization, SubscriptionAuthorizer {
|
|
4
27
|
private factManager;
|
|
5
28
|
private store;
|
|
6
29
|
private keystore;
|
|
@@ -11,7 +34,10 @@ export declare class AuthorizationKeystore implements Authorization {
|
|
|
11
34
|
getOrCreateUserFact(userIdentity: UserIdentity): Promise<import("jinaga").FactRecord>;
|
|
12
35
|
read(userIdentity: UserIdentity | null, start: FactReference[], specification: Specification): Promise<import("jinaga").ProjectedResult[]>;
|
|
13
36
|
feed(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FactFeed>;
|
|
37
|
+
feedWithDistribution(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FeedResult>;
|
|
38
|
+
feedPreVerified(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FactFeed>;
|
|
14
39
|
load(userIdentity: UserIdentity, references: FactReference[]): Promise<FactEnvelope[]>;
|
|
15
40
|
save(userIdentity: UserIdentity | null, envelopes: FactEnvelope[]): Promise<FactEnvelope[]>;
|
|
16
41
|
verifyDistribution(userIdentity: UserIdentity | null, feeds: Specification[], namedStart: ReferencesByName): Promise<void>;
|
|
42
|
+
verifyDistributionOrIntersect(userIdentity: UserIdentity | null, specification: Specification, namedStart: ReferencesByName): Promise<DistributionBranchesResult>;
|
|
17
43
|
}
|
|
@@ -54,6 +54,15 @@ class AuthorizationKeystore {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
feed(userIdentity, specification, start, bookmark) {
|
|
57
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
58
|
+
const result = yield this.feedWithDistribution(userIdentity, specification, start, bookmark);
|
|
59
|
+
if (result.type === "denied") {
|
|
60
|
+
throw new jinaga_1.Forbidden(result.reason);
|
|
61
|
+
}
|
|
62
|
+
return result.feed;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
feedWithDistribution(userIdentity, specification, start, bookmark) {
|
|
57
66
|
return __awaiter(this, void 0, void 0, function* () {
|
|
58
67
|
if (this.distributionEngine) {
|
|
59
68
|
const userReference = userIdentity
|
|
@@ -62,18 +71,36 @@ class AuthorizationKeystore {
|
|
|
62
71
|
const namedStart = specification.given.reduce((map, g, index) => (Object.assign(Object.assign({}, map), { [g.label.name]: start[index] })), {});
|
|
63
72
|
const canDistribute = yield this.distributionEngine.canDistributeToAll([specification], namedStart, userReference);
|
|
64
73
|
if (canDistribute.type === "failure") {
|
|
65
|
-
|
|
74
|
+
return { type: "denied", reason: canDistribute.reason };
|
|
66
75
|
}
|
|
67
76
|
const factFeed = yield this.store.feed(specification, start, bookmark);
|
|
68
77
|
const factReferences = factFeed.tuples
|
|
69
78
|
.flatMap(tuple => tuple.facts)
|
|
70
79
|
.filter((value, index, self) => self.findIndex((0, jinaga_1.factReferenceEquals)(value)) === index);
|
|
71
80
|
this.distributedFacts.add(factReferences, userReference);
|
|
72
|
-
return factFeed;
|
|
81
|
+
return { type: "success", feed: factFeed };
|
|
73
82
|
}
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
return { type: "success", feed: yield this.store.feed(specification, start, bookmark) };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
feedPreVerified(userIdentity, specification, start, bookmark) {
|
|
87
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88
|
+
// Caller has already established that this feed is safe to serve
|
|
89
|
+
// (e.g. produced by intersectForSubscribe — the spec lifts its own
|
|
90
|
+
// authorization condition into the matches and therefore self-
|
|
91
|
+
// filters). Skip the redundant per-query distribution check, but
|
|
92
|
+
// keep tracking the returned references so /load stays consistent.
|
|
93
|
+
const factFeed = yield this.store.feed(specification, start, bookmark);
|
|
94
|
+
if (this.distributionEngine) {
|
|
95
|
+
const userReference = userIdentity
|
|
96
|
+
? yield this.keystore.getUserFact(userIdentity)
|
|
97
|
+
: null;
|
|
98
|
+
const factReferences = factFeed.tuples
|
|
99
|
+
.flatMap(tuple => tuple.facts)
|
|
100
|
+
.filter((value, index, self) => self.findIndex((0, jinaga_1.factReferenceEquals)(value)) === index);
|
|
101
|
+
this.distributedFacts.add(factReferences, userReference);
|
|
76
102
|
}
|
|
103
|
+
return factFeed;
|
|
77
104
|
});
|
|
78
105
|
}
|
|
79
106
|
load(userIdentity, references) {
|
|
@@ -150,6 +177,27 @@ class AuthorizationKeystore {
|
|
|
150
177
|
}
|
|
151
178
|
});
|
|
152
179
|
}
|
|
180
|
+
verifyDistributionOrIntersect(userIdentity, specification, namedStart) {
|
|
181
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
182
|
+
const start = specification.given.map(g => namedStart[g.label.name]);
|
|
183
|
+
if (!this.distributionEngine) {
|
|
184
|
+
return { type: "success", branches: [{ start, specification }] };
|
|
185
|
+
}
|
|
186
|
+
const userReference = userIdentity
|
|
187
|
+
? yield this.keystore.getUserFact(userIdentity)
|
|
188
|
+
: null;
|
|
189
|
+
const targetFeeds = (0, jinaga_1.buildFeeds)(specification);
|
|
190
|
+
const canDistribute = yield this.distributionEngine.canDistributeToAll(targetFeeds, namedStart, userReference);
|
|
191
|
+
if (canDistribute.type === "success") {
|
|
192
|
+
return { type: "success", branches: [{ start, specification }] };
|
|
193
|
+
}
|
|
194
|
+
const result = yield this.distributionEngine.intersectForSubscribe(start, specification, userReference);
|
|
195
|
+
if (!result.intersected) {
|
|
196
|
+
return { type: "denied", reason: canDistribute.reason };
|
|
197
|
+
}
|
|
198
|
+
return { type: "success", branches: result.branches };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
153
201
|
}
|
|
154
202
|
exports.AuthorizationKeystore = AuthorizationKeystore;
|
|
155
203
|
//# sourceMappingURL=authorization-keystore.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authorization-keystore.js","sourceRoot":"","sources":["../../src/authorization/authorization-keystore.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,mCAiBgB;
|
|
1
|
+
{"version":3,"file":"authorization-keystore.js","sourceRoot":"","sources":["../../src/authorization/authorization-keystore.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,mCAiBgB;AAoChB,qEAAgE;AAEhE,MAAa,qBAAqB;IAK9B,YACY,WAAwB,EACxB,KAAc,EACd,QAAkB,EAC1B,kBAA6C,EAC7C,iBAA2C;QAJnC,gBAAW,GAAX,WAAW,CAAa;QACxB,UAAK,GAAL,KAAK,CAAS;QACd,aAAQ,GAAR,QAAQ,CAAU;QALtB,qBAAgB,GAAyB,IAAI,6CAAoB,EAAE,CAAC;QASxE,IAAI,CAAC,mBAAmB,GAAG,kBAAkB;YACzC,IAAI,4BAAmB,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACvD,IAAI,CAAC,kBAAkB,GAAG,iBAAiB;YACvC,IAAI,2BAAkB,CAAC,iBAAiB,EAAE,KAAK,CAAC,CAAC;IACzD,CAAC;IAEK,mBAAmB,CAAC,YAA0B;;YAChD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,CAAC,YAAY,CAAC,CAAC;YACvE,MAAM,SAAS,GAAG;gBACA;oBACV,IAAI,EAAE,QAAQ;oBACd,UAAU,EAAE,EAAE;iBACjB;aACJ,CAAC;YACF,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACjC,OAAO,QAAQ,CAAC;QACpB,CAAC;KAAA;IAEK,IAAI,CAAC,YAAiC,EAAE,KAAsB,EAAE,aAA4B;;YAC9F,IAAI,IAAI,CAAC,kBAAkB,EAAE;gBACzB,MAAM,aAAa,GAAyB,YAAY;oBACpD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC;oBAC/C,CAAC,CAAC,IAAI,CAAC;gBACX,MAAM,UAAU,GAAG,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,iCAC1D,GAAG,KACN,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,IAC9B,EAAE,EAAsB,CAAC,CAAC;gBAC5B,6DAA6D;gBAC7D,MAAM,KAAK,GAAG,IAAA,mBAAU,EAAC,aAAa,CAAC,CAAC;gBACxC,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;gBACzG,IAAI,aAAa,CAAC,IAAI,KAAK,SAAS,EAAE;oBAClC,MAAM,IAAI,kBAAS,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;iBAC7C;aACJ;YACD,OAAO,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC;QAC7D,CAAC;KAAA;IAEK,IAAI,CAAC,YAAiC,EAAE,aAA4B,EAAE,KAAsB,EAAE,QAAgB;;YAChH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,oBAAoB,CAAC,YAAY,EAAE,aAAa,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YAC7F,IAAI,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE;gBAC1B,MAAM,IAAI,kBAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;aACtC;YACD,OAAO,MAAM,CAAC,IAAI,CAAC;QACvB,CAAC;KAAA;IAEK,oBAAoB,CAAC,YAAiC,EAAE,aAA4B,EAAE,KAAsB,EAAE,QAAgB;;YAChI,IAAI,IAAI,CAAC,kBAAkB,EAAE;gBACzB,MAAM,aAAa,GAAyB,YAAY;oBACpD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC;oBAC/C,CAAC,CAAC,IAAI,CAAC;gBACX,MAAM,UAAU,GAAG,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,iCAC1D,GAAG,KACN,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,IAC9B,EAAE,EAAsB,CAAC,CAAC;gBAC5B,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,kBAAkB,CAAC,CAAC,aAAa,CAAC,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;gBACnH,IAAI,aAAa,CAAC,IAAI,KAAK,SAAS,EAAE;oBAClC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,EAAE,CAAC;iBAC3D;gBACD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;gBACvE,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM;qBACjC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC;qBAC7B,MAAM,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,IAAA,4BAAmB,EAAC,KAAK,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC;gBAC1F,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,cAAc,EAAE,aAAa,CAAC,CAAC;gBACzD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;aAC9C;YACD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,EAAE,QAAQ,CAAC,EAAE,CAAC;QAC5F,CAAC;KAAA;IAEK,eAAe,CAAC,YAAiC,EAAE,aAA4B,EAAE,KAAsB,EAAE,QAAgB;;YAC3H,iEAAiE;YACjE,mEAAmE;YACnE,+DAA+D;YAC/D,iEAAiE;YACjE,mEAAmE;YACnE,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YACvE,IAAI,IAAI,CAAC,kBAAkB,EAAE;gBACzB,MAAM,aAAa,GAAyB,YAAY;oBACpD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC;oBAC/C,CAAC,CAAC,IAAI,CAAC;gBACX,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM;qBACjC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC;qBAC7B,MAAM,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,IAAA,4BAAmB,EAAC,KAAK,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC;gBAC1F,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,cAAc,EAAE,aAAa,CAAC,CAAC;aAC5D;YACD,OAAO,QAAQ,CAAC;QACpB,CAAC;KAAA;IAEK,IAAI,CAAC,YAA0B,EAAE,UAA2B;;YAC9D,IAAI,IAAI,CAAC,kBAAkB,EAAE;gBACzB,MAAM,QAAQ,GAAG,YAAY,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;gBACrF,MAAM,aAAa,GAAG,IAAI,CAAC,gBAAgB,CAAC,WAAW,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;gBAC9E,IAAI,CAAC,aAAa,EAAE;oBAChB,MAAM,IAAI,kBAAS,CAAC,cAAc,CAAC,CAAC;iBACvC;aACJ;YACD,OAAO,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACnD,CAAC;KAAA;IAEK,IAAI,CAAC,YAAiC,EAAE,SAAyB;;YACnE,IAAI,IAAI,CAAC,mBAAmB,EAAE;gBAC1B,MAAM,QAAQ,GAAG,YAAY,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;gBACrF,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,mBAAmB,CAAC,cAAc,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;gBACnF,MAAM,QAAQ,GAAc,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;oBACjF,CAAC,CAAC,CAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAE;oBAC/B,CAAC,CAAC,EAAE,CAAC;gBACT,MAAM,WAAW,GAAG,OAAO;qBACtB,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,QAAQ,IAAI,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;qBACtF,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;gBACtB,MAAM,WAAW,GAAG,CAAC,YAAY,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC;oBACxD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,YAAY,EAAE,WAAW,CAAC;oBAC1D,CAAC,CAAC,EAAE,CAAC;gBACT,MAAM,mBAAmB,GAAmB,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE;oBACxD,MAAM,MAAM,GAAG,IAAA,4BAAmB,EAAC,CAAC,CAAC,IAAI,CAAC,CAAC;oBAC3C,MAAM,QAAQ,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;oBACrD,IAAI,CAAC,QAAQ,EAAE;wBACX,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;qBACnD;oBACD,IAAI,CAAC,CAAC,OAAO,KAAK,QAAQ,EAAE;wBACxB,MAAM,UAAU,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;wBACzD,MAAM,cAAc,GAAG,UAAU;4BAC7B,CAAC,CAAC,UAAU,CAAC,UAAU;4BACvB,CAAC,CAAC,EAAE,CAAC;wBACT,OAAO;4BACH,IAAI,EAAE,CAAC,CAAC,IAAI;4BACZ,UAAU,EAAE,QAAQ,CAAC,UAAU;iCAC1B,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;iCAClD,MAAM,CAAC,cAAc,CAAC;yBAC9B,CAAC;qBACL;yBACI,IAAI,CAAC,CAAC,OAAO,KAAK,UAAU,EAAE;wBAC/B,OAAO,QAAQ,CAAC;qBACnB;yBACI;wBACD,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;qBAC1C;gBACL,CAAC,CAAC,CAAC;gBACH,MAAM,cAAc,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;gBACxE,OAAO,cAAc,CAAC;aACzB;iBACI;gBACD,MAAM,cAAc,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;gBAC9D,OAAO,cAAc,CAAC;aACzB;QACL,CAAC;KAAA;IAEK,kBAAkB,CAAC,YAAiC,EAAE,KAAsB,EAAE,UAA4B;;YAC5G,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE;gBAC1B,OAAO;aACV;YAED,MAAM,aAAa,GAAyB,YAAY;gBACpD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC;gBAC/C,CAAC,CAAC,IAAI,CAAC;YACX,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;YACzG,IAAI,aAAa,CAAC,IAAI,KAAK,SAAS,EAAE;gBAClC,MAAM,IAAI,kBAAS,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;aAC7C;QACL,CAAC;KAAA;IAEK,6BAA6B,CAC/B,YAAiC,EACjC,aAA4B,EAC5B,UAA4B;;YAE5B,MAAM,KAAK,GAAG,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;YACrE,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE;gBAC1B,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;aACpE;YACD,MAAM,aAAa,GAAyB,YAAY;gBACpD,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,YAAY,CAAC;gBAC/C,CAAC,CAAC,IAAI,CAAC;YACX,MAAM,WAAW,GAAG,IAAA,mBAAU,EAAC,aAAa,CAAC,CAAC;YAC9C,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,kBAAkB,CAAC,WAAW,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;YAC/G,IAAI,aAAa,CAAC,IAAI,KAAK,SAAS,EAAE;gBAClC,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;aACpE;YACD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,qBAAqB,CAAC,KAAK,EAAE,aAAa,EAAE,aAAa,CAAC,CAAC;YACxG,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE;gBACrB,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,EAAE,CAAC;aAC3D;YACD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC1D,CAAC;KAAA;CACJ;AAlMD,sDAkMC"}
|
package/dist/http/router.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Handler } from "express";
|
|
2
2
|
import { Authorization, FactManager, FeedCache, ProfileMessage } from "jinaga";
|
|
3
|
+
import { SubscriptionAuthorizer } from "../authorization/authorization-keystore";
|
|
3
4
|
export interface RequestUser {
|
|
4
5
|
provider: string;
|
|
5
6
|
id: string;
|
|
@@ -11,7 +12,8 @@ export declare class HttpRouter {
|
|
|
11
12
|
private feedCache;
|
|
12
13
|
private allowedOrigin;
|
|
13
14
|
handler: Handler;
|
|
14
|
-
|
|
15
|
+
private intersectedFeedOwners;
|
|
16
|
+
constructor(factManager: FactManager, authorization: Authorization & SubscriptionAuthorizer, feedCache: FeedCache, allowedOrigin: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void));
|
|
15
17
|
private login;
|
|
16
18
|
private load;
|
|
17
19
|
private save;
|
|
@@ -24,6 +26,7 @@ export declare class HttpRouter {
|
|
|
24
26
|
private readWithStreaming;
|
|
25
27
|
private write;
|
|
26
28
|
private feeds;
|
|
29
|
+
private queryFeed;
|
|
27
30
|
private feed;
|
|
28
31
|
private streamFeed;
|
|
29
32
|
private streamAllInitialResults;
|
package/dist/http/router.js
CHANGED
|
@@ -228,6 +228,9 @@ function postReadWithStreaming(method) {
|
|
|
228
228
|
}
|
|
229
229
|
};
|
|
230
230
|
}
|
|
231
|
+
function subscriptionOwnerKey(userIdentity) {
|
|
232
|
+
return userIdentity ? `${userIdentity.provider}|${userIdentity.id}` : null;
|
|
233
|
+
}
|
|
231
234
|
function serializeUserIdentity(user) {
|
|
232
235
|
if (!user) {
|
|
233
236
|
return null;
|
|
@@ -293,6 +296,15 @@ class HttpRouter {
|
|
|
293
296
|
this.authorization = authorization;
|
|
294
297
|
this.feedCache = feedCache;
|
|
295
298
|
this.allowedOrigin = allowedOrigin;
|
|
299
|
+
// Map from feed hash → owning user key, for feeds the server cleared
|
|
300
|
+
// via intersection. The cached spec self-filters by the lifted auth
|
|
301
|
+
// condition (the requesting user's fact is baked into start), so it
|
|
302
|
+
// is safe to skip the per-query distribution check — but ONLY for
|
|
303
|
+
// the same user who originally requested the subscription. Another
|
|
304
|
+
// authenticated user who somehow obtained the hash must go through
|
|
305
|
+
// the normal authorization.feed path, which will deny their request
|
|
306
|
+
// since the intersected spec is not authorizable in its own right.
|
|
307
|
+
this.intersectedFeedOwners = new Map();
|
|
296
308
|
const router = (0, express_1.Router)();
|
|
297
309
|
const applyAllowOrigin = this.applyAllowOrigin.bind(this);
|
|
298
310
|
router.get('/login', applyAllowOrigin, getAuthenticate(user => this.login(user)));
|
|
@@ -436,16 +448,81 @@ class HttpRouter {
|
|
|
436
448
|
throw new jinaga_1.Invalid(failures.join("\n"));
|
|
437
449
|
}
|
|
438
450
|
const namedStart = specification.given.reduce((map, g, index) => (Object.assign(Object.assign({}, map), { [g.label.name]: start[index] })), {});
|
|
439
|
-
//
|
|
440
|
-
|
|
451
|
+
// Resolve distribution. When the user is not authorized for the
|
|
452
|
+
// original spec but the distribution rules can rewrite it via
|
|
453
|
+
// intersection (jinaga.js#130), accept the subscription with
|
|
454
|
+
// empty results and let the inverse engine activate it later.
|
|
455
|
+
//
|
|
456
|
+
// Even when no rule applies at all, accept the subscription:
|
|
457
|
+
// the per-query distribution check inside streamFeed /
|
|
458
|
+
// feed returns "denied" and keeps the stream alive so the
|
|
459
|
+
// client can be notified the moment the authorizing fact
|
|
460
|
+
// arrives. Failing /feeds here would force the client to
|
|
461
|
+
// re-subscribe on a poll loop.
|
|
441
462
|
const userIdentity = serializeUserIdentity(user);
|
|
442
|
-
yield this.authorization.
|
|
443
|
-
|
|
463
|
+
const intersectResult = yield this.authorization.verifyDistributionOrIntersect(userIdentity, specification, namedStart);
|
|
464
|
+
let branches;
|
|
465
|
+
let intersected;
|
|
466
|
+
if (intersectResult.type === "denied") {
|
|
467
|
+
jinaga_1.Trace.warn(`/feeds accepting spec without applicable distribution rule: ${intersectResult.reason}`);
|
|
468
|
+
branches = [{ start, specification }];
|
|
469
|
+
intersected = false;
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
branches = intersectResult.branches;
|
|
473
|
+
intersected = branches.length !== 1 || branches[0].specification !== specification;
|
|
474
|
+
}
|
|
475
|
+
const ownerKey = subscriptionOwnerKey(userIdentity);
|
|
476
|
+
const feedHashes = [];
|
|
477
|
+
for (const branch of branches) {
|
|
478
|
+
const branchNamedStart = branch.specification.given.reduce((map, g, index) => (Object.assign(Object.assign({}, map), { [g.label.name]: branch.start[index] })), {});
|
|
479
|
+
const branchFeeds = (0, jinaga_1.buildFeeds)(branch.specification);
|
|
480
|
+
const branchHashes = this.feedCache.addFeeds(branchFeeds, branchNamedStart);
|
|
481
|
+
feedHashes.push(...branchHashes);
|
|
482
|
+
if (intersected) {
|
|
483
|
+
for (const hash of branchHashes) {
|
|
484
|
+
// Bind the hash to its owner so a different
|
|
485
|
+
// authenticated user who obtains it cannot reuse
|
|
486
|
+
// the cached spec — which carries the original
|
|
487
|
+
// owner's user fact in its start — to fetch
|
|
488
|
+
// facts authorized for that owner.
|
|
489
|
+
//
|
|
490
|
+
// The map allows only one owner per hash, which
|
|
491
|
+
// relies on the invariant that intersected specs
|
|
492
|
+
// bind the requesting user's fact into start —
|
|
493
|
+
// making the hash user-specific. If two distinct
|
|
494
|
+
// owners ever collide on the same hash, that
|
|
495
|
+
// invariant has broken in intersectForSubscribe;
|
|
496
|
+
// warn so we notice instead of silently
|
|
497
|
+
// overwriting.
|
|
498
|
+
const existing = this.intersectedFeedOwners.get(hash);
|
|
499
|
+
if (existing !== undefined && existing !== ownerKey) {
|
|
500
|
+
jinaga_1.Trace.warn(`Intersected feed hash ${hash} re-bound from owner ${existing} to ${ownerKey}; intersection invariant may be broken`);
|
|
501
|
+
}
|
|
502
|
+
this.intersectedFeedOwners.set(hash, ownerKey);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
444
506
|
return {
|
|
445
507
|
feeds: feedHashes
|
|
446
508
|
};
|
|
447
509
|
}));
|
|
448
510
|
}
|
|
511
|
+
queryFeed(feedHash, userIdentity, specification, start, bookmark) {
|
|
512
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
513
|
+
const cachedOwner = this.intersectedFeedOwners.get(feedHash);
|
|
514
|
+
const requesterOwner = subscriptionOwnerKey(userIdentity);
|
|
515
|
+
if (cachedOwner !== undefined && cachedOwner === requesterOwner) {
|
|
516
|
+
const feed = yield this.authorization.feedPreVerified(userIdentity, specification, start, bookmark);
|
|
517
|
+
return { type: "success", feed };
|
|
518
|
+
}
|
|
519
|
+
// For everyone else (different user, anonymous mismatch, or a
|
|
520
|
+
// non-intersected hash) go through the normal distribution-checked
|
|
521
|
+
// path. A "denied" result means the streaming / polling caller
|
|
522
|
+
// serves an empty page and keeps the subscription alive.
|
|
523
|
+
return yield this.authorization.feedWithDistribution(userIdentity, specification, start, bookmark);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
449
526
|
feed(user, params, query) {
|
|
450
527
|
return jinaga_1.Trace.dependency("feed", params["hash"], () => __awaiter(this, void 0, void 0, function* () {
|
|
451
528
|
var _a;
|
|
@@ -460,12 +537,19 @@ class HttpRouter {
|
|
|
460
537
|
const bookmark = (_a = query["b"]) !== null && _a !== void 0 ? _a : "";
|
|
461
538
|
const userIdentity = serializeUserIdentity(user);
|
|
462
539
|
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
463
|
-
const
|
|
540
|
+
const result = yield this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
541
|
+
if (result.type === "denied") {
|
|
542
|
+
// The subscription has been accepted; treat poll-time
|
|
543
|
+
// distribution failures as an empty page so the client
|
|
544
|
+
// can keep polling and pick up results when an
|
|
545
|
+
// authorizing fact arrives.
|
|
546
|
+
return { references: [], bookmark };
|
|
547
|
+
}
|
|
464
548
|
// Return distinct fact references from all the tuples.
|
|
465
|
-
const references =
|
|
549
|
+
const references = result.feed.tuples.flatMap(t => t.facts).filter((value, index, self) => self.findIndex(f => f.hash === value.hash && f.type === value.type) === index);
|
|
466
550
|
const response = {
|
|
467
551
|
references,
|
|
468
|
-
bookmark:
|
|
552
|
+
bookmark: result.feed.bookmark
|
|
469
553
|
};
|
|
470
554
|
return response;
|
|
471
555
|
}));
|
|
@@ -497,7 +581,7 @@ class HttpRouter {
|
|
|
497
581
|
// Continuous initial query until exhausted
|
|
498
582
|
console.log(`[StreamFeed:${connectionId}] Starting initial data streaming...`);
|
|
499
583
|
const initialStart = Date.now();
|
|
500
|
-
bookmark = yield this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
|
|
584
|
+
bookmark = yield this.streamAllInitialResults(feedHash, userIdentity, feedDefinition, start, bookmark, stream);
|
|
501
585
|
const initialDuration = Date.now() - initialStart;
|
|
502
586
|
console.log(`[StreamFeed:${connectionId}] Initial data streaming complete - Duration: ${initialDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
503
587
|
// Set up real-time listeners after initial data is complete
|
|
@@ -516,7 +600,7 @@ class HttpRouter {
|
|
|
516
600
|
if (matchingResults.length != 0) {
|
|
517
601
|
console.log(`[StreamFeed:${connectionId}] Processing matching results...`);
|
|
518
602
|
const responseStart = Date.now();
|
|
519
|
-
bookmark = yield this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
603
|
+
bookmark = yield this.streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
520
604
|
const responseDuration = Date.now() - responseStart;
|
|
521
605
|
console.log(`[StreamFeed:${connectionId}] Feed response sent - Duration: ${responseDuration}ms, New bookmark: ${bookmark || 'empty'}`);
|
|
522
606
|
}
|
|
@@ -534,9 +618,42 @@ class HttpRouter {
|
|
|
534
618
|
}));
|
|
535
619
|
});
|
|
536
620
|
console.log(`[StreamFeed:${connectionId}] All listeners registered - Count: ${listeners.length}`);
|
|
621
|
+
// Anchor listeners (jinaga.js#129): the inverse-listener path
|
|
622
|
+
// above only fires when a descendant of the given arrives. If
|
|
623
|
+
// the given fact itself isn't yet in the store when the client
|
|
624
|
+
// subscribes, neither the initial query nor any later inverse
|
|
625
|
+
// can deliver results until the given is recorded — and the
|
|
626
|
+
// given fact has no descendants whose save would re-trigger the
|
|
627
|
+
// inverse with the matching givenHash. Register a listener per
|
|
628
|
+
// unique anchor that re-runs streamFeedResponse the moment a
|
|
629
|
+
// saved fact matches the anchor's (type, hash).
|
|
630
|
+
const uniqueAnchors = start.filter((s, i, arr) => arr.findIndex(other => other.type === s.type && other.hash === s.hash) === i);
|
|
631
|
+
console.log(`[StreamFeed:${connectionId}] Registering ${uniqueAnchors.length} anchor listener(s)`);
|
|
632
|
+
const anchorListeners = uniqueAnchors.map(anchor => {
|
|
633
|
+
const anchorSpec = {
|
|
634
|
+
given: [{ label: { name: "x", type: anchor.type }, conditions: [] }],
|
|
635
|
+
matches: [],
|
|
636
|
+
projection: { type: "fact", label: "x" }
|
|
637
|
+
};
|
|
638
|
+
return this.factManager.addSpecificationListener(anchorSpec, (results) => __awaiter(this, void 0, void 0, function* () {
|
|
639
|
+
try {
|
|
640
|
+
const matched = results.some(pr => {
|
|
641
|
+
const ref = pr.tuple && pr.tuple.x;
|
|
642
|
+
return ref && ref.type === anchor.type && ref.hash === anchor.hash;
|
|
643
|
+
});
|
|
644
|
+
if (matched) {
|
|
645
|
+
console.log(`[StreamFeed:${connectionId}] Anchor fact arrived - Type: ${anchor.type}, Hash: ${anchor.hash.substring(0, 8)}...`);
|
|
646
|
+
bookmark = yield this.streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
console.error(`[StreamFeed:${connectionId}] ERROR in anchor listener: ${error}`);
|
|
651
|
+
}
|
|
652
|
+
}));
|
|
653
|
+
});
|
|
537
654
|
stream.done(() => {
|
|
538
655
|
const cleanupStart = Date.now();
|
|
539
|
-
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length}
|
|
656
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length + anchorListeners.length} listener(s)`);
|
|
540
657
|
for (let i = 0; i < listeners.length; i++) {
|
|
541
658
|
try {
|
|
542
659
|
this.factManager.removeSpecificationListener(listeners[i]);
|
|
@@ -546,6 +663,14 @@ class HttpRouter {
|
|
|
546
663
|
console.error(`[StreamFeed:${connectionId}] ERROR removing listener ${i + 1}: ${error}`);
|
|
547
664
|
}
|
|
548
665
|
}
|
|
666
|
+
for (let i = 0; i < anchorListeners.length; i++) {
|
|
667
|
+
try {
|
|
668
|
+
this.factManager.removeSpecificationListener(anchorListeners[i]);
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
console.error(`[StreamFeed:${connectionId}] ERROR removing anchor listener ${i + 1}: ${error}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
549
674
|
const cleanupDuration = Date.now() - cleanupStart;
|
|
550
675
|
const totalDuration = Date.now() - startTime;
|
|
551
676
|
console.log(`[StreamFeed:${connectionId}] CLEANUP COMPLETE - Cleanup duration: ${cleanupDuration}ms, Total connection duration: ${totalDuration}ms`);
|
|
@@ -560,7 +685,7 @@ class HttpRouter {
|
|
|
560
685
|
}
|
|
561
686
|
});
|
|
562
687
|
}
|
|
563
|
-
streamAllInitialResults(userIdentity, feedDefinition, start, initialBookmark, stream) {
|
|
688
|
+
streamAllInitialResults(feedHash, userIdentity, feedDefinition, start, initialBookmark, stream) {
|
|
564
689
|
return __awaiter(this, void 0, void 0, function* () {
|
|
565
690
|
const streamId = Math.random().toString(36).substring(2, 8);
|
|
566
691
|
console.log(`[InitialResults:${streamId}] Starting initial data fetch - Bookmark: ${initialBookmark || 'empty'}`);
|
|
@@ -573,7 +698,17 @@ class HttpRouter {
|
|
|
573
698
|
while (hasMoreResults && pageCount < maxPages) {
|
|
574
699
|
const pageStart = Date.now();
|
|
575
700
|
console.log(`[InitialResults:${streamId}] Fetching page ${pageCount + 1} - Bookmark: ${bookmark || 'empty'}`);
|
|
576
|
-
const
|
|
701
|
+
const queryResult = yield this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
702
|
+
if (queryResult.type === "denied") {
|
|
703
|
+
// The subscription has already been accepted; tearing
|
|
704
|
+
// down the stream on a distribution failure would force
|
|
705
|
+
// the client to re-subscribe just to keep waiting.
|
|
706
|
+
// Treat as empty and let the listener path re-evaluate
|
|
707
|
+
// when an authorizing fact arrives.
|
|
708
|
+
console.log(`[InitialResults:${streamId}] Distribution failed (${queryResult.reason}) - serving empty and keeping stream live`);
|
|
709
|
+
return bookmark;
|
|
710
|
+
}
|
|
711
|
+
const results = queryResult.feed;
|
|
577
712
|
const fetchDuration = Date.now() - pageStart;
|
|
578
713
|
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} fetched - Tuples: ${results.tuples.length}, Duration: ${fetchDuration}ms`);
|
|
579
714
|
// Check if we got results
|
|
@@ -627,13 +762,21 @@ class HttpRouter {
|
|
|
627
762
|
return bookmark;
|
|
628
763
|
});
|
|
629
764
|
}
|
|
630
|
-
streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, skipIfEmpty = false) {
|
|
765
|
+
streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, skipIfEmpty = false) {
|
|
631
766
|
return __awaiter(this, void 0, void 0, function* () {
|
|
632
767
|
const responseId = Math.random().toString(36).substring(2, 6);
|
|
633
768
|
const startTime = Date.now();
|
|
634
769
|
console.log(`[FeedResponse:${responseId}] Starting feed response - Bookmark: ${bookmark || 'empty'}, Skip if empty: ${skipIfEmpty}`);
|
|
635
770
|
const feedStart = Date.now();
|
|
636
|
-
const
|
|
771
|
+
const queryResult = yield this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
772
|
+
if (queryResult.type === "denied") {
|
|
773
|
+
// The subscription stays live across distribution failures
|
|
774
|
+
// so the inverse / anchor listeners can deliver results
|
|
775
|
+
// once the authorizing fact arrives.
|
|
776
|
+
console.log(`[FeedResponse:${responseId}] Distribution failed (${queryResult.reason}) - keeping stream live`);
|
|
777
|
+
return bookmark;
|
|
778
|
+
}
|
|
779
|
+
const results = queryResult.feed;
|
|
637
780
|
const feedDuration = Date.now() - feedStart;
|
|
638
781
|
console.log(`[FeedResponse:${responseId}] Authorization feed complete - Tuples: ${results.tuples.length}, Duration: ${feedDuration}ms`);
|
|
639
782
|
// Return distinct fact references from all the tuples.
|