jinaga-server 3.7.4 → 3.8.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.
Files changed (39) hide show
  1. package/.claude/skills/degrees-of-freedom/.openskills.json +7 -0
  2. package/.claude/skills/degrees-of-freedom/SKILL.md +18 -0
  3. package/.claude/skills/degrees-of-freedom/degrees-of-freedom-constitution.md +142 -0
  4. package/.claude/skills/night-shift-setup/.openskills.json +7 -0
  5. package/.claude/skills/night-shift-setup/SKILL.md +95 -0
  6. package/.claude/skills/night-shift-setup/configuring-a-repository.md +111 -0
  7. package/.claude/skills/night-shift-setup/creating-the-log.md +59 -0
  8. package/.claude/skills/night-shift-setup/creating-the-routines.md +80 -0
  9. package/.claude/skills/night-shift-setup/log-model.factual +971 -0
  10. package/.claude/skills/night-shift-worker/.openskills.json +7 -0
  11. package/.claude/skills/night-shift-worker/SKILL.md +199 -0
  12. package/.claude/skills/night-shift-worker/night-shift-log.md +72 -0
  13. package/.claude/skills/night-shift-worker/scripts/register-stack.sh +173 -0
  14. package/.claude/skills/night-shift-worker/stacking.md +57 -0
  15. package/.claude/skills/night-shift-worker/verifying-a-merged-fix.md +16 -0
  16. package/.claude/skills/refining-issues/.openskills.json +7 -0
  17. package/.claude/skills/refining-issues/SKILL.md +327 -0
  18. package/.claude/skills/refining-issues/references/worked-example.md +234 -0
  19. package/.github/workflows/main.yml +2 -2
  20. package/.night-shift/config.md +84 -0
  21. package/.night-shift/skills.md +17 -0
  22. package/README.md +21 -0
  23. package/dist/http/router.js +207 -80
  24. package/dist/http/router.js.map +1 -1
  25. package/dist/http/stream.d.ts +0 -1
  26. package/dist/http/stream.js +6 -17
  27. package/dist/http/stream.js.map +1 -1
  28. package/dist/jinaga-server.js +74 -7
  29. package/dist/jinaga-server.js.map +1 -1
  30. package/integration-test/jinaga-test/package-lock.json +4 -4
  31. package/integration-test/jinaga-test/package.json +1 -1
  32. package/package.json +2 -2
  33. package/src/http/router.ts +249 -99
  34. package/src/http/stream.ts +21 -36
  35. package/src/jinaga-server.ts +92 -4
  36. package/test/http/errorStatusMappingSpec.ts +351 -0
  37. package/test/http/streamLogVolumeSpec.ts +284 -0
  38. package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
  39. package/test/jinaga-server/tracePoolSpec.ts +368 -0
@@ -253,22 +253,82 @@ function getPool(uriOrPool: string | Pool, pools: { [uri: string]: Pool; }): Poo
253
253
  }
254
254
  }
255
255
 
256
+ type PoolSettingDeclaration = {
257
+ variable: string,
258
+ defaultValue: number,
259
+ minimum: number
260
+ };
261
+
262
+ // The settings of a pool that this server builds for itself, each named by the
263
+ // environment variable that sets it. A pool the caller supplies is used as
264
+ // given, so none of these applies to it.
265
+ const poolSettingDeclarations = {
266
+ // A pool of zero connections can serve nothing, so one is the minimum.
267
+ max: { variable: "POSTGRES_POOL_MAX", defaultValue: 10, minimum: 1 },
268
+ idleTimeoutMillis: { variable: "POSTGRES_IDLE_TIMEOUT_MILLIS", defaultValue: 30000, minimum: 0 },
269
+ // Zero means wait forever for a connection to come free.
270
+ connectionTimeoutMillis: { variable: "POSTGRES_CONNECTION_TIMEOUT_MILLIS", defaultValue: 0, minimum: 0 }
271
+ } satisfies { [setting: string]: PoolSettingDeclaration };
272
+
273
+ type PoolSettings = {
274
+ [setting in keyof typeof poolSettingDeclarations]: number
275
+ };
276
+
277
+ // The one rule by which every pool setting is read from the environment. An
278
+ // absent or empty variable takes the default; anything that is not an integer
279
+ // at or above the setting's minimum is refused rather than resolved, so the
280
+ // environment either names a pool that can be built or no pool at all.
281
+ function readPoolSetting({ variable, defaultValue, minimum }: PoolSettingDeclaration): number {
282
+ const value = process.env[variable];
283
+ if (value === undefined || value.trim() === '') {
284
+ return defaultValue;
285
+ }
286
+ const parsed = /^\d+$/.test(value.trim()) ? parseInt(value.trim(), 10) : NaN;
287
+ if (isNaN(parsed) || parsed < minimum) {
288
+ throw new Error(`The environment variable ${variable} is set to ${JSON.stringify(value)}, which is not an integer of at least ${minimum}.`);
289
+ }
290
+ return parsed;
291
+ }
292
+
293
+ function readPoolSettings(): PoolSettings {
294
+ return {
295
+ max: readPoolSetting(poolSettingDeclarations.max),
296
+ idleTimeoutMillis: readPoolSetting(poolSettingDeclarations.idleTimeoutMillis),
297
+ connectionTimeoutMillis: readPoolSetting(poolSettingDeclarations.connectionTimeoutMillis)
298
+ };
299
+ }
300
+
256
301
  function createPool(postgresUri: string): Pool {
302
+ const settings = readPoolSettings();
257
303
  const postgresPool = new Pool({
258
304
  connectionString: postgresUri,
259
- idleTimeoutMillis: process.env.POSTGRES_IDLE_TIMEOUT_MILLIS ?
260
- parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MILLIS) :
261
- 30000,
305
+ ...settings
262
306
  });
263
307
 
308
+ Trace.info(`Postgres pool created with max ${settings.max}, idleTimeoutMillis ${settings.idleTimeoutMillis}, connectionTimeoutMillis ${settings.connectionTimeoutMillis}`);
309
+
264
310
  tracePool(postgresPool);
265
311
 
266
312
  return postgresPool;
267
313
  }
268
314
 
315
+ const poolWindowMilliseconds = 10000;
316
+
269
317
  export function tracePool(postgresPool: Pool) {
318
+ // Pool pressure is reported once per window of time rather than once per
319
+ // checkout, so the output rate no longer follows the acquire rate and no
320
+ // longer peaks when the pool is saturated. The window carries the largest
321
+ // waiting count it saw, so a spike that comes and goes inside one window
322
+ // still appears in the log.
323
+ let acquiredInWindow = false;
324
+ let peakWaiting = 0;
325
+
270
326
  postgresPool.on('acquire', (client) => {
271
- Trace.metric('Postgres acquired', poolMetrics());
327
+ acquiredInWindow = true;
328
+ observeWaiting();
329
+ });
330
+ postgresPool.on('release', (err, client) => {
331
+ observeWaiting();
272
332
  });
273
333
  postgresPool.on('connect', (client) => {
274
334
  Trace.metric('Postgres connected', poolMetrics());
@@ -280,6 +340,34 @@ export function tracePool(postgresPool: Pool) {
280
340
  Trace.error(err);
281
341
  });
282
342
 
343
+ // The window closes on a timer so that a storm which stops in the middle
344
+ // of a window still reports its peak. The timer is unreferenced, so it
345
+ // does not hold the process open, and it stops once the pool has ended.
346
+ const windowTimer = setInterval(closeWindow, poolWindowMilliseconds);
347
+ windowTimer.unref?.();
348
+
349
+ function observeWaiting() {
350
+ if (postgresPool.waitingCount > peakWaiting) {
351
+ peakWaiting = postgresPool.waitingCount;
352
+ }
353
+ }
354
+
355
+ function closeWindow() {
356
+ if (postgresPool.ended) {
357
+ clearInterval(windowTimer);
358
+ return;
359
+ }
360
+ if (acquiredInWindow) {
361
+ const metrics = poolMetrics();
362
+ Trace.metric('Postgres pool', { ...metrics, peakWaiting });
363
+ if (peakWaiting > 0) {
364
+ Trace.warn(`Postgres pool saturated: ${peakWaiting} waiting at peak, ${metrics.waiting} waiting of ${metrics.total} at window close.`);
365
+ }
366
+ }
367
+ acquiredInWindow = false;
368
+ peakWaiting = 0;
369
+ }
370
+
283
371
  function poolMetrics() {
284
372
  return {
285
373
  total: postgresPool.totalCount,
@@ -0,0 +1,351 @@
1
+ import express from "express";
2
+ import { AddressInfo } from "net";
3
+ import { Server } from "http";
4
+
5
+ import {
6
+ Authorization,
7
+ AuthorizationRules,
8
+ buildModel,
9
+ dehydrateFact,
10
+ DistributionRules,
11
+ FactEnvelope,
12
+ FactManager,
13
+ FactReference,
14
+ FeedCache,
15
+ Invalid,
16
+ MemoryStore,
17
+ NetworkNoOp,
18
+ ObservableSource,
19
+ PassThroughFork,
20
+ PredecessorNotResolvedError,
21
+ User
22
+ } from "jinaga";
23
+
24
+ import { AuthorizationKeystore, SubscriptionAuthorizer } from "../../src/authorization/authorization-keystore";
25
+ import { HttpRouter, RequestUser } from "../../src/http/router";
26
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
27
+
28
+ class Workspace {
29
+ static Type = "ErrorStatus.Workspace" as const;
30
+ public type = Workspace.Type;
31
+ constructor(public owner: User, public identifier: string) { }
32
+ }
33
+
34
+ class Application {
35
+ static Type = "ErrorStatus.Application" as const;
36
+ public type = Application.Type;
37
+ constructor(public workspace: Workspace, public name: string) { }
38
+ }
39
+
40
+ const model = buildModel(b => b
41
+ .type(User)
42
+ .type(Workspace, w => w.predecessor("owner", User))
43
+ .type(Application, a => a.predecessor("workspace", Workspace))
44
+ );
45
+
46
+ const ownerIdentity = { provider: "mock", id: "owner" };
47
+
48
+ const requestUser: RequestUser = {
49
+ provider: ownerIdentity.provider,
50
+ id: ownerIdentity.id,
51
+ profile: {} as any
52
+ };
53
+
54
+ async function createHarness() {
55
+ const store = new MemoryStore();
56
+ const keystore = new MemoryKeystore();
57
+ const ownerFact = await keystore.getOrCreateUserFact(ownerIdentity);
58
+ const owner = new User(ownerFact.fields.publicKey);
59
+
60
+ const factManager = new FactManager(
61
+ new PassThroughFork(store),
62
+ new ObservableSource(store),
63
+ store,
64
+ new NetworkNoOp(),
65
+ []
66
+ );
67
+
68
+ const authorizationRules = new AuthorizationRules(model)
69
+ .any(User.Type)
70
+ .any(Workspace.Type)
71
+ .type(Application, app => app.workspace.owner);
72
+ const authorization = new AuthorizationKeystore(
73
+ factManager, store, keystore, authorizationRules, new DistributionRules([]));
74
+ const router = new HttpRouter(factManager, authorization, new FeedCache(), "*");
75
+
76
+ return { store, authorization, router, owner };
77
+ }
78
+
79
+ // A batch holding a successor whose predecessors are in neither the batch nor
80
+ // storage. jinaga's AuthorizationEngine detects this in its topological sort
81
+ // and reports it as a plain Error.
82
+ function unclosedBatch(owner: User): FactEnvelope[] {
83
+ const records = dehydrateFact(new Application(new Workspace(owner, "workspace-1"), "application-1"));
84
+ return records
85
+ .filter(fact => fact.type === Application.Type)
86
+ .map(fact => ({ fact, signatures: [] }));
87
+ }
88
+
89
+ function graphSourceOf(envelopes: FactEnvelope[]) {
90
+ return {
91
+ read: async (onEnvelopes: (envelopes: FactEnvelope[]) => Promise<void>) => {
92
+ await onEnvelopes(envelopes);
93
+ }
94
+ };
95
+ }
96
+
97
+ // A router whose only job is to reject the authorization call with a chosen
98
+ // error, so the translation can be exercised for a route whose input grammar
99
+ // cannot provoke the error on its own.
100
+ function createRouterWithFailingSave(error: Error): HttpRouter {
101
+ const store = new MemoryStore();
102
+ const factManager = new FactManager(
103
+ new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
104
+ const keystore = new MemoryKeystore();
105
+ const authorization = {
106
+ getOrCreateUserFact: (identity: any) => keystore.getOrCreateUserFact(identity),
107
+ save: () => Promise.reject(error)
108
+ } as unknown as Authorization & SubscriptionAuthorizer;
109
+ return new HttpRouter(factManager, authorization, new FeedCache(), "*");
110
+ }
111
+
112
+ async function captureError(action: () => Promise<unknown>): Promise<any> {
113
+ try {
114
+ await action();
115
+ } catch (error) {
116
+ return error;
117
+ }
118
+ throw new Error("Expected the operation to fail, but it succeeded.");
119
+ }
120
+
121
+ // Issue #182 findings 1 and 2. These drive the real AuthorizationEngine rather
122
+ // than a hand-written message, so the classification is pinned to what jinaga
123
+ // actually throws. That mattered concretely: the original version of this
124
+ // translation matched the engine's prose, and 6.11.3 reworded it one release
125
+ // later — these tests failed rather than letting the 400 regress to a 500 in
126
+ // production. The unresolved-predecessor path is now an instanceof check on
127
+ // the typed error jinaga.js#234 introduced.
128
+ describe("save authorization error translation", () => {
129
+ it("reports an unresolved predecessor from the real engine as a typed error", async () => {
130
+ const { authorization, owner } = await createHarness();
131
+
132
+ const error = await captureError(() =>
133
+ authorization.save(ownerIdentity, unclosedBatch(owner)));
134
+
135
+ // The untranslated baseline: still not an Invalid, so handleError alone
136
+ // would map it to 500 — but now it carries the structured detail the
137
+ // translation reads instead of parsing prose.
138
+ expect(error).toBeInstanceOf(PredecessorNotResolvedError);
139
+ expect(error).not.toBeInstanceOf(Invalid);
140
+ expect(error.missingPredecessors.map((r: FactReference) => r.type)).toContain(Workspace.Type);
141
+ expect(error.unresolvedFacts.map((r: FactReference) => r.type)).toContain(Application.Type);
142
+ });
143
+
144
+ it("translates the unresolved predecessor to Invalid on /save", async () => {
145
+ const { router, owner } = await createHarness();
146
+
147
+ const error = await captureError(() =>
148
+ (router as any).save(requestUser, graphSourceOf(unclosedBatch(owner))));
149
+
150
+ expect(error).toBeInstanceOf(Invalid);
151
+ // Names the predecessor that is actually missing — the structured
152
+ // fields make this possible, where the prose match could only report
153
+ // the successors that failed to sort.
154
+ expect(error.message).toContain(Workspace.Type);
155
+ expect(error.message).toContain("was not found in storage and was not included in the request");
156
+ });
157
+
158
+ it("translates the unresolved predecessor to Invalid on /write", async () => {
159
+ // The declaration grammar /write accepts cannot express a dangling
160
+ // predecessor, so the engine error is captured from the reachable
161
+ // /save path and replayed here. The route still needs the translation:
162
+ // it makes the same authorization call, and this keeps both routes
163
+ // reading one upstream error type.
164
+ const { authorization, owner } = await createHarness();
165
+ const engineError = await captureError(() =>
166
+ authorization.save(ownerIdentity, unclosedBatch(owner)));
167
+
168
+ const router = createRouterWithFailingSave(engineError);
169
+ const declaration =
170
+ `let workspace: ${Workspace.Type} = { owner: me, identifier: "workspace-1" }\n` +
171
+ `let application: ${Application.Type} = { workspace: workspace, name: "application-1" }\n`;
172
+
173
+ const error = await captureError(() => (router as any).write(requestUser, declaration));
174
+
175
+ expect(error).toBeInstanceOf(Invalid);
176
+ expect(error.message).toContain("was not found in storage and was not included in the request");
177
+ });
178
+
179
+ it("still translates the authorization-rule shape fixed by issue #175", async () => {
180
+ const router = createRouterWithFailingSave(
181
+ new Error("The fact ErrorStatus.Workspace:abc123 is not defined."));
182
+
183
+ const error = await captureError(() =>
184
+ (router as any).save(requestUser, graphSourceOf([])));
185
+
186
+ expect(error).toBeInstanceOf(Invalid);
187
+ expect(error.message).toContain("ErrorStatus.Workspace:abc123 is required to authorize this save");
188
+ });
189
+ });
190
+
191
+ // Issue #182 findings 3 and 4, exercised over real HTTP so the assertions are
192
+ // on the status line a client actually receives.
193
+ describe("HTTP status mapping", () => {
194
+ let server: Server;
195
+ let baseUrl: string;
196
+
197
+ async function listen(app: express.Express) {
198
+ server = app.listen(0);
199
+ await new Promise<void>(resolve => server.once("listening", () => resolve()));
200
+ const port = (server.address() as AddressInfo).port;
201
+ baseUrl = `http://127.0.0.1:${port}`;
202
+ }
203
+
204
+ afterEach(async () => {
205
+ if (server) {
206
+ await new Promise<void>(resolve => server.close(() => resolve()));
207
+ }
208
+ });
209
+
210
+ describe("with no body parser registered", () => {
211
+ beforeEach(async () => {
212
+ const { router } = await createHarness();
213
+ const app = express();
214
+ app.use(router.handler);
215
+ await listen(app);
216
+ });
217
+
218
+ // Each of these parses the body synchronously in the handler. Express
219
+ // used to hand the throw to its default error handler, reporting the
220
+ // client's malformed request as a 500.
221
+ it.each([
222
+ ["/write", "text/plain"],
223
+ ["/read", "text/plain"],
224
+ ["/feeds", "text/plain"],
225
+ ["/load", "application/json"]
226
+ ])("answers 400 for %s when the body cannot be read", async (path, contentType) => {
227
+ const response = await fetch(`${baseUrl}${path}`, {
228
+ method: "POST",
229
+ headers: { "Content-Type": contentType },
230
+ body: contentType === "application/json" ? "{}" : "anything"
231
+ });
232
+
233
+ expect(response.status).toBe(400);
234
+ });
235
+
236
+ // The text/plain-only routes name the parser the caller is missing,
237
+ // rather than falling back to parseString's generic content-type
238
+ // message.
239
+ it.each(["/write", "/read"])("names express.text() in the 400 body for %s", async (path) => {
240
+ const response = await fetch(`${baseUrl}${path}`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "text/plain" },
243
+ body: "anything"
244
+ });
245
+
246
+ expect(await response.text()).toContain("express.text()");
247
+ });
248
+
249
+ // Saying what arrived tells the caller which end is misconfigured.
250
+ it("names the Content-Type that was received", async () => {
251
+ const response = await fetch(`${baseUrl}/write`, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: "{}"
255
+ });
256
+
257
+ expect(await response.text()).toContain("Received Content-Type: application/json");
258
+ });
259
+
260
+ it("says so when no Content-Type was received", async () => {
261
+ const response = await fetch(`${baseUrl}/write`, {
262
+ method: "POST",
263
+ body: new Blob(["anything"])
264
+ });
265
+
266
+ expect(await response.text()).toContain("No Content-Type header was received");
267
+ });
268
+
269
+ // The echoed Content-Type is client-supplied, so it must not be able to
270
+ // carry markup into the body.
271
+ it("neutralizes markup in the echoed Content-Type", async () => {
272
+ const response = await fetch(`${baseUrl}/write`, {
273
+ method: "POST",
274
+ headers: { "Content-Type": "text/plain<script>alert(1)</script>" },
275
+ body: "anything"
276
+ });
277
+ const body = await response.text();
278
+
279
+ expect(response.status).toBe(400);
280
+ expect(body).not.toContain("<script>");
281
+ expect(body).toContain("&lt;script&gt;");
282
+ });
283
+
284
+ it("caps the length of the echoed Content-Type", async () => {
285
+ const response = await fetch(`${baseUrl}/write`, {
286
+ method: "POST",
287
+ headers: { "Content-Type": `text/plain;padding=${"x".repeat(500)}` },
288
+ body: "anything"
289
+ });
290
+ const body = await response.text();
291
+
292
+ expect(response.status).toBe(400);
293
+ expect(body).toContain("…");
294
+ expect(body.length).toBeLessThan(300);
295
+ });
296
+
297
+ it("marks error responses nosniff", async () => {
298
+ const response = await fetch(`${baseUrl}/write`, {
299
+ method: "POST",
300
+ headers: { "Content-Type": "text/plain" },
301
+ body: "anything"
302
+ });
303
+
304
+ expect(response.status).toBe(400);
305
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
306
+ });
307
+ });
308
+
309
+ it("answers 500 without echoing the internal error message", async () => {
310
+ const store = new MemoryStore();
311
+ const factManager = new FactManager(
312
+ new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
313
+ const secret = "connection to postgres://internal-host:5432 refused";
314
+ const failingAuthorization = {
315
+ load: () => Promise.reject(new Error(secret))
316
+ } as unknown as Authorization & SubscriptionAuthorizer;
317
+ const router = new HttpRouter(factManager, failingAuthorization, new FeedCache(), "*");
318
+
319
+ const app = express();
320
+ app.use(express.json());
321
+ app.use(router.handler);
322
+ await listen(app);
323
+
324
+ const response = await fetch(`${baseUrl}/load`, {
325
+ method: "POST",
326
+ headers: { "Content-Type": "application/json" },
327
+ body: JSON.stringify({ references: [] })
328
+ });
329
+ const body = await response.text();
330
+
331
+ expect(response.status).toBe(500);
332
+ expect(body).toBe("Internal server error");
333
+ expect(body).not.toContain("internal-host");
334
+ // A plain-text body labelled text/html invites a browser to render it.
335
+ expect(response.headers.get("content-type")).toContain("text/plain");
336
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
337
+ });
338
+
339
+ it("answers 404 with a distinguishable body for a feed route miss", async () => {
340
+ const { router } = await createHarness();
341
+ const app = express();
342
+ app.use(router.handler);
343
+ await listen(app);
344
+
345
+ // A hash the feed cache has never seen resolves to FeedNotFound, which
346
+ // is a different 404 from the route miss below.
347
+ const cacheMiss = await fetch(`${baseUrl}/feeds/unknownhash`);
348
+ expect(cacheMiss.status).toBe(404);
349
+ expect(await cacheMiss.text()).toBe("feed_not_found");
350
+ });
351
+ });