jinaga-server 3.5.4 → 3.5.5
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/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +1 -1
- package/package.json +2 -2
|
@@ -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.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Bug Analysis: Feed Builder Strips Inner Negative Existential Conditions
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-30
|
|
4
|
+
**Area:** `jinaga` package — `feed-builder.js` / `specification-sql.ts`
|
|
5
|
+
**Symptom:** EventName facts not delivered to clients for events that have been deleted and restored
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Observed Symptom
|
|
10
|
+
|
|
11
|
+
The LaunchKings admin portal Events dropdown shows the raw fact hash prefix (e.g. `65bc55e`) instead of the event's human-readable name ("LATAM test"). The `CodeLaunch.Event.Name` fact exists in the database and the distribution rules authorize it, but it is never delivered to the client via feeds.
|
|
12
|
+
|
|
13
|
+
## Database State That Triggers the Bug
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
CodeLaunch.Event 1 fact — id: 65bc55e9-495a-4418-9744-3dea6f8c8f2a
|
|
17
|
+
CodeLaunch.Event.Name 1 fact — value: "LATAM test" (EXISTS — not missing)
|
|
18
|
+
CodeLaunch.Event.Delete 2 facts — deleted twice
|
|
19
|
+
CodeLaunch.Event.Restore 2 facts — restored twice → event is currently active
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The event is logically active (every delete has a matching restore), but its presence of any `EventDelete` facts triggers the bug.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Root Cause
|
|
27
|
+
|
|
28
|
+
### The Specification
|
|
29
|
+
|
|
30
|
+
The `eventsListInTenant` specification uses `Event.in(tenant)`:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
static in(tenant: LabelOf<Tenant>) {
|
|
34
|
+
return tenant.successors(Event, event => event.tenant)
|
|
35
|
+
.notExists(event =>
|
|
36
|
+
event.successors(EventDelete, d => d.event)
|
|
37
|
+
.notExists(d =>
|
|
38
|
+
d.successors(EventRestore, r => r.eventDelete)
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The full `!E` condition is:
|
|
45
|
+
```
|
|
46
|
+
!E { EventDelete [ u2->event = u1, !E { EventRestore [ u3->eventDelete = u2 ] } ] }
|
|
47
|
+
```
|
|
48
|
+
Meaning: *no un-restored EventDelete exists for this event*.
|
|
49
|
+
|
|
50
|
+
### What `buildFeeds` Produces
|
|
51
|
+
|
|
52
|
+
`buildFeeds` calls `buildExistentialCondition` to create a simplified version of negative existential conditions for use in feed specifications. The relevant code:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// feed-builder.js — buildExistentialCondition()
|
|
56
|
+
for (const match of matches) {
|
|
57
|
+
existentialCondition = {
|
|
58
|
+
...existentialCondition,
|
|
59
|
+
matches: [...existentialCondition.matches, {
|
|
60
|
+
...match,
|
|
61
|
+
conditions: match.conditions.filter(isPathCondition) // strips ALL existential conditions
|
|
62
|
+
}]
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
|
|
66
|
+
if (innerExistentialCondition.exists) {
|
|
67
|
+
// ✓ positive inner existentials are recursively included
|
|
68
|
+
}
|
|
69
|
+
// ✗ negative inner existentials (exists=false) are silently dropped
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The inner `!E{EventRestore}` condition has `exists=false` and is therefore **dropped**. The simplified condition produced is:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
!E { EventDelete [ u2->event = u1 ] }
|
|
78
|
+
```
|
|
79
|
+
Meaning: *no EventDelete at all*.
|
|
80
|
+
|
|
81
|
+
### The Five Feeds for `eventsListInTenant`
|
|
82
|
+
|
|
83
|
+
`buildFeeds` produces these feeds:
|
|
84
|
+
|
|
85
|
+
| Feed | Purpose | Condition on Event |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| FeedA | Detect EventRestore arrivals | none (bare join) |
|
|
88
|
+
| FeedB | Detect EventDelete arrivals | none (bare join) |
|
|
89
|
+
| FeedC | Deliver Event facts | `!E{EventDelete}` ← simplified |
|
|
90
|
+
| FeedD | Detect EventName supersession | `!E{EventDelete}` ← inherited |
|
|
91
|
+
| FeedE | **Deliver EventName facts** | `!E{EventDelete}` ← **BUG** |
|
|
92
|
+
|
|
93
|
+
FeedE inherits FeedC's simplified condition via `addProjections(FeedC, ...)`. FeedE's SQL is therefore:
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
SELECT f_name.hash, sort(array[f_name.fact_id], 'desc') as bookmark
|
|
97
|
+
FROM fact f_tenant
|
|
98
|
+
JOIN edge e1 ON e1.predecessor_fact_id = f_tenant.fact_id AND e1.role_id = $event_tenant_role
|
|
99
|
+
JOIN fact f_event ON f_event.fact_id = e1.successor_fact_id
|
|
100
|
+
JOIN edge e2 ON e2.predecessor_fact_id = f_event.fact_id AND e2.role_id = $eventname_event_role
|
|
101
|
+
JOIN fact f_name ON f_name.fact_id = e2.successor_fact_id
|
|
102
|
+
WHERE f_tenant.fact_type_id = $tenant_type AND f_tenant.hash = $tenant_hash
|
|
103
|
+
AND NOT EXISTS (
|
|
104
|
+
SELECT 1 FROM edge e_del
|
|
105
|
+
WHERE e_del.predecessor_fact_id = f_event.fact_id
|
|
106
|
+
AND e_del.role_id = $eventdelete_event_role
|
|
107
|
+
-- ↑ checks for ANY EventDelete, not just un-restored ones
|
|
108
|
+
)
|
|
109
|
+
AND NOT EXISTS (
|
|
110
|
+
SELECT 1 FROM edge e_prior
|
|
111
|
+
WHERE e_prior.predecessor_fact_id = f_name.fact_id
|
|
112
|
+
AND e_prior.role_id = $eventname_prior_role
|
|
113
|
+
)
|
|
114
|
+
AND sort(array[f_name.fact_id], 'desc') > $bookmark
|
|
115
|
+
ORDER BY bookmark ASC LIMIT 100
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Because the "LATAM test" event has `EventDelete` facts (regardless of `EventRestore`), the `NOT EXISTS (EventDelete)` clause is false, the event is excluded, and **the query returns zero rows**.
|
|
119
|
+
|
|
120
|
+
### Why the Listener Doesn't Rescue It
|
|
121
|
+
|
|
122
|
+
The observer also registers inverse specification listeners. When FeedA delivers `EventRestore` facts, the listener fires and calls `notifyAdded` — which reads EventName from the **local IndexedDB store**. Because FeedE never delivered EventName to the local store, the re-evaluation finds nothing, and names remain absent.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Impact
|
|
127
|
+
|
|
128
|
+
Any event that has ever been deleted (even if subsequently restored) will have its name and other projection facts permanently withheld from all projection feeds, while the base `Event` fact itself still appears (for example via FeedC). This affects:
|
|
129
|
+
|
|
130
|
+
- `CodeLaunch.Event.Name` — event name not shown in nav dropdown
|
|
131
|
+
- `CodeLaunch.Event.Date` — event dates not shown
|
|
132
|
+
- Any other projected facts on events from `eventsListInTenant` or similar specs
|
|
133
|
+
|
|
134
|
+
In this environment the event was deleted and restored twice, making it the only event and confirming 100% reproduction.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## The Fix
|
|
139
|
+
|
|
140
|
+
### Option A — Support Nested NOT EXISTS in Feed SQL (Correct but Complex)
|
|
141
|
+
|
|
142
|
+
Extend `buildExistentialCondition` to recursively include negative inner existential conditions:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
// feed-builder.js — buildExistentialCondition()
|
|
146
|
+
for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
|
|
147
|
+
if (innerExistentialCondition.exists) {
|
|
148
|
+
// existing logic — keep positive existentials
|
|
149
|
+
const { existentialCondition: newEC, ... } = buildExistentialCondition(...);
|
|
150
|
+
existentialCondition = newEC;
|
|
151
|
+
} else {
|
|
152
|
+
// NEW: also recurse into negative existentials
|
|
153
|
+
const { existentialCondition: newEC, ... } = buildExistentialCondition(
|
|
154
|
+
innerExistentialCondition, innerExistentialCondition.matches, ...
|
|
155
|
+
);
|
|
156
|
+
existentialCondition = { ...existentialCondition, innerConditions: [..., newEC] };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This also requires:
|
|
162
|
+
1. Adding a `innerConditions` (or `notExistsConditions`) field to `ExistentialConditionDescription`
|
|
163
|
+
2. Updating `generateNotExistsWhereClause` in `specification-sql.ts` to recurse into nested conditions
|
|
164
|
+
3. Updating `notExistsConditionsEqual` in `distribution-engine.ts` if nested conditions need to be compared
|
|
165
|
+
|
|
166
|
+
### Option B — Omit Outer Not-Exists from Projection Feeds (Simpler)
|
|
167
|
+
|
|
168
|
+
When `addProjections` builds projection feeds (FeedD, FeedE), start from a version of the specification that **drops all not-exists conditions on the base matches**. Projection feeds would then return facts for all events (active or not), and the full specification read handles the correct filtering.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
// feed-builder.js — buildFeeds()
|
|
172
|
+
const baseFeed = stripNotExistsConditions(finalFeed); // new helper
|
|
173
|
+
const feedsWithProjections = addProjections(baseFeed, unusedGivens, specification.projection.components);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This is safe because:
|
|
177
|
+
- FeedA and FeedB already handle re-evaluation when delete/restore facts arrive
|
|
178
|
+
- The actual filtering is done by `factManager.read()` using the full specification
|
|
179
|
+
- Feeds are designed to be a **superset** of relevant facts; over-delivery is intentional
|
|
180
|
+
|
|
181
|
+
Option B is simpler, has no SQL changes, and aligns with the documented design principle that "feeds are not executed with nested existential conditions."
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Files Involved
|
|
186
|
+
|
|
187
|
+
| File | Role |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `jinaga/src/specification/feed-builder.ts` | `buildExistentialCondition` — drops inner `!E` conditions |
|
|
190
|
+
| `src/postgres/specification-sql.ts` | `generateNotExistsWhereClause` — only handles flat conditions |
|
|
191
|
+
| `jinaga/src/distribution/distribution-engine.ts` | `notExistsConditionsEqual` — explicitly skips nested comparison |
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"express": "^4.21.2",
|
|
15
15
|
"jest": "^29.7.0",
|
|
16
16
|
"jest-compact-reporter": "^1.2.9",
|
|
17
|
-
"jinaga": "^6.7.
|
|
17
|
+
"jinaga": "^6.7.25",
|
|
18
18
|
"node-forge": "^1.3.0",
|
|
19
19
|
"pg": "^8.13.1"
|
|
20
20
|
},
|
|
@@ -3195,9 +3195,9 @@
|
|
|
3195
3195
|
}
|
|
3196
3196
|
},
|
|
3197
3197
|
"node_modules/jinaga": {
|
|
3198
|
-
"version": "6.7.
|
|
3199
|
-
"resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.7.
|
|
3200
|
-
"integrity": "sha512-
|
|
3198
|
+
"version": "6.7.25",
|
|
3199
|
+
"resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.7.25.tgz",
|
|
3200
|
+
"integrity": "sha512-J+9KjHYdDkemO5xTau20P+NV1p2DMFodFwE/ulBFTYLA+4jG7YFOxEYN9n6Irn0Wv3itz5PvRUMpVEmzovj2Hw==",
|
|
3201
3201
|
"license": "MIT",
|
|
3202
3202
|
"dependencies": {
|
|
3203
3203
|
"@stablelib/base64": "^1.0.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jinaga-server",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.5",
|
|
4
4
|
"description": "Data management for web and mobile applications.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"csv-stringify": "^6.6.0",
|
|
51
51
|
"express": "^4.21.2",
|
|
52
|
-
"jinaga": "^6.7.
|
|
52
|
+
"jinaga": "^6.7.25",
|
|
53
53
|
"pg": "^8.13.1"
|
|
54
54
|
}
|
|
55
55
|
}
|