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.
@@ -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: ${{ secrets.NPM_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.13",
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.13",
3199
- "resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.7.13.tgz",
3200
- "integrity": "sha512-WhSg3bWvUr1WnC4lnpzswJIZ45bMWxRlKXlEhOsofI86jXJSH7tHUqXWKZ9UJihc5/8iGxJUnM65zWZmPa+aFA==",
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",
@@ -18,7 +18,7 @@
18
18
  "express": "^4.21.2",
19
19
  "jest": "^29.7.0",
20
20
  "jest-compact-reporter": "^1.2.9",
21
- "jinaga": "^6.7.13",
21
+ "jinaga": "^6.7.25",
22
22
  "node-forge": "^1.3.0",
23
23
  "pg": "^8.13.1"
24
24
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinaga-server",
3
- "version": "3.5.4",
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.14",
52
+ "jinaga": "^6.7.25",
53
53
  "pg": "^8.13.1"
54
54
  }
55
55
  }