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
@@ -0,0 +1,284 @@
1
+ import express from "express";
2
+ import { Server } from "http";
3
+ import { AddressInfo } from "net";
4
+
5
+ import {
6
+ AuthorizationRules,
7
+ buildModel,
8
+ DistributionRules,
9
+ FactManager,
10
+ FeedCache,
11
+ FeedResponse,
12
+ MemoryStore,
13
+ NetworkNoOp,
14
+ ObservableSource,
15
+ PassThroughFork,
16
+ User
17
+ } from "jinaga";
18
+
19
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
20
+ import { HttpRouter } from "../../src/http/router";
21
+ import { Stream } from "../../src/http/stream";
22
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
23
+
24
+ // Issue #192. A streaming response used to describe itself step by step: one
25
+ // line per message written, one per queued item replayed, one per done handler
26
+ // executed, one before and one after every feed. The line count therefore grew
27
+ // with traffic rather than with the number of events worth knowing about, and a
28
+ // 23 GB replicator log filled a production disk.
29
+ //
30
+ // These tests count lines. They deliberately do not match the text of the lines
31
+ // that remain, so the wording stays free to improve; the two that do read a line
32
+ // assert only the facts criterion 1 requires the end line to carry.
33
+
34
+ // Every console method a line could be written through, so a line moved from
35
+ // log to info is still counted rather than silently disappearing.
36
+ const CONSOLE_METHODS = ["log", "info", "debug", "warn", "error"] as const;
37
+
38
+ class ConsoleCapture {
39
+ private readonly spies: jest.SpyInstance[] = [];
40
+ readonly lines: string[] = [];
41
+
42
+ constructor() {
43
+ for (const method of CONSOLE_METHODS) {
44
+ this.spies.push(jest.spyOn(console, method).mockImplementation((...args: any[]) => {
45
+ // A single call can carry several lines, and it is lines that
46
+ // cost disk, so split rather than counting calls.
47
+ this.lines.push(...args.map(a => String(a)).join(" ").split("\n"));
48
+ }));
49
+ }
50
+ }
51
+
52
+ restore(): void {
53
+ for (const spy of this.spies) {
54
+ spy.mockRestore();
55
+ }
56
+ }
57
+ }
58
+
59
+ function countLines(action: () => void): number {
60
+ const capture = new ConsoleCapture();
61
+ try {
62
+ action();
63
+ } finally {
64
+ capture.restore();
65
+ }
66
+ return capture.lines.length;
67
+ }
68
+
69
+ describe("Stream log volume", () => {
70
+ // Issue #192 Tests, item 1.
71
+ it("writes the same number of lines for one fed item as for a thousand", () => {
72
+ const one = countLines(() => {
73
+ const stream = new Stream<number>();
74
+ stream.next(() => { });
75
+ stream.feed(1);
76
+ stream.close();
77
+ });
78
+
79
+ const thousand = countLines(() => {
80
+ const stream = new Stream<number>();
81
+ stream.next(() => { });
82
+ for (let i = 0; i < 1000; i++) {
83
+ stream.feed(i);
84
+ }
85
+ stream.close();
86
+ });
87
+
88
+ expect(thousand).toBe(one);
89
+ });
90
+
91
+ // Issue #192 Tests, item 2. The handler is added after the data arrives, so
92
+ // the whole queue is replayed to it, and several done handlers are
93
+ // registered: the two remaining per-item loops in stream.ts.
94
+ it("writes no line per queued item or per done handler", () => {
95
+ function run(items: number, doneHandlers: number): number {
96
+ return countLines(() => {
97
+ const stream = new Stream<number>();
98
+ for (let i = 0; i < items; i++) {
99
+ stream.feed(i);
100
+ }
101
+ for (let i = 0; i < doneHandlers; i++) {
102
+ stream.done(() => { });
103
+ }
104
+ stream.next(() => { });
105
+ stream.close();
106
+ });
107
+ }
108
+
109
+ expect(run(50, 10)).toBe(run(1, 1));
110
+ });
111
+
112
+ // Issue #192 criterion 3. The volume comes from the success path, so the
113
+ // failure path keeps its lines: these are the events an operator reading a
114
+ // quiet log is looking for.
115
+ it("still reports a handler that throws", () => {
116
+ const lines = countLines(() => {
117
+ const stream = new Stream<number>();
118
+ stream.next(() => { throw new Error("handler failed"); });
119
+ stream.feed(1);
120
+ stream.close();
121
+ });
122
+
123
+ expect(lines).toBeGreaterThan(0);
124
+ });
125
+
126
+ it("still reports a done handler that throws", () => {
127
+ const lines = countLines(() => {
128
+ const stream = new Stream<number>();
129
+ stream.done(() => { throw new Error("done handler failed"); });
130
+ stream.close();
131
+ });
132
+
133
+ expect(lines).toBeGreaterThan(0);
134
+ });
135
+
136
+ it("still reports a feed to a closed stream", () => {
137
+ const lines = countLines(() => {
138
+ const stream = new Stream<number>();
139
+ stream.close();
140
+ stream.feed(1);
141
+ });
142
+
143
+ expect(lines).toBeGreaterThan(0);
144
+ });
145
+ });
146
+
147
+ // Criterion 1 is about router.ts and stream.ts together, so these drive the real
148
+ // Express handler over a real socket. Only streamFeed and feed are stubbed, so
149
+ // that the test controls how many messages the stream delivers without standing
150
+ // up a feed cache, a specification and a listener.
151
+ describe("streaming request log volume", () => {
152
+ // One test issues two requests, so every server it starts is tracked and
153
+ // closed; an untracked one keeps the process alive after the suite ends.
154
+ const servers: Server[] = [];
155
+
156
+ async function listen(messages: FeedResponse | null, stream: Stream<FeedResponse> | null) {
157
+ const store = new MemoryStore();
158
+ const factManager = new FactManager(
159
+ new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
160
+ const model = buildModel(b => b.type(User));
161
+ const authorization = new AuthorizationKeystore(
162
+ factManager, store, new MemoryKeystore(), new AuthorizationRules(model), new DistributionRules([]));
163
+ const router = new HttpRouter(factManager, authorization, new FeedCache(), "*");
164
+ (router as any).streamFeed = () => Promise.resolve(stream);
165
+ (router as any).feed = () => Promise.resolve(messages);
166
+
167
+ const app = express();
168
+ app.use(router.handler);
169
+ const server = app.listen(0);
170
+ servers.push(server);
171
+ await new Promise<void>(resolve => server.once("listening", () => resolve()));
172
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
173
+ }
174
+
175
+ afterEach(async () => {
176
+ const closing = servers.splice(0, servers.length);
177
+ for (const server of closing) {
178
+ // A streaming response leaves its socket open, so close() alone
179
+ // would wait for a connection the test has finished with.
180
+ server.closeAllConnections();
181
+ await new Promise<void>(resolve => server.close(() => resolve()));
182
+ }
183
+ });
184
+
185
+ async function waitFor(condition: () => boolean) {
186
+ for (let attempt = 0; attempt < 500 && !condition(); attempt++) {
187
+ await new Promise<void>(resolve => setTimeout(resolve, 10));
188
+ }
189
+ expect(condition()).toBe(true);
190
+ }
191
+
192
+ // Feeds `messages` messages through a live streaming request and returns
193
+ // every line the request wrote, from the moment it was issued to the moment
194
+ // the stream ended.
195
+ async function streamingRequest(messages: number): Promise<string[]> {
196
+ const stream = new Stream<FeedResponse>();
197
+ const baseUrl = await listen(null, stream);
198
+
199
+ const capture = new ConsoleCapture();
200
+ try {
201
+ // Resolves once the handler has flushed headers, which it does
202
+ // before attaching the handler that counts messages.
203
+ const response = await fetch(`${baseUrl}/feeds/feed-hash`, {
204
+ headers: { "Accept": "application/x-jinaga-feed-stream" }
205
+ });
206
+ // The handler ends the socket rather than the chunked response, so
207
+ // the client sees the body terminate. Reading it is only here to
208
+ // release the connection.
209
+ const drained = response.text().catch(() => undefined);
210
+
211
+ for (let i = 0; i < messages; i++) {
212
+ stream.feed({ references: [], bookmark: `bookmark-${i}` });
213
+ }
214
+ stream.close();
215
+
216
+ await waitFor(() => capture.lines.length >= 2);
217
+ const lines = [...capture.lines];
218
+ await drained;
219
+ return lines;
220
+ } finally {
221
+ capture.restore();
222
+ }
223
+ }
224
+
225
+ // Issue #192 criterion 1.
226
+ it("writes one line when the stream opens and one when it ends", async () => {
227
+ expect(await streamingRequest(1)).toHaveLength(2);
228
+ });
229
+
230
+ it("writes the same number of lines for a thousand messages as for one", async () => {
231
+ const one = await streamingRequest(1);
232
+ const thousand = await streamingRequest(1000);
233
+
234
+ expect(thousand).toHaveLength(one.length);
235
+ });
236
+
237
+ it("carries the messages sent, the duration and the reason in the end line", async () => {
238
+ const lines = await streamingRequest(3);
239
+ const end = lines[lines.length - 1];
240
+
241
+ expect(end).toContain("Messages sent: 3");
242
+ expect(end).toMatch(/Duration: \d+ms/);
243
+ expect(end).toContain("done");
244
+ });
245
+
246
+ it("reports a client disconnect as the reason the stream ended", async () => {
247
+ const stream = new Stream<FeedResponse>();
248
+ const baseUrl = await listen(null, stream);
249
+
250
+ const capture = new ConsoleCapture();
251
+ try {
252
+ const controller = new AbortController();
253
+ await fetch(`${baseUrl}/feeds/feed-hash`, {
254
+ headers: { "Accept": "application/x-jinaga-feed-stream" },
255
+ signal: controller.signal
256
+ });
257
+ controller.abort();
258
+
259
+ await waitFor(() => capture.lines.length >= 2);
260
+ const lines = [...capture.lines];
261
+
262
+ expect(lines).toHaveLength(2);
263
+ expect(lines[lines.length - 1]).toContain("disconnect");
264
+ } finally {
265
+ capture.restore();
266
+ }
267
+ });
268
+
269
+ // Issue #192 criterion 2.
270
+ it("writes no line for a plain GET", async () => {
271
+ const baseUrl = await listen({ references: [], bookmark: "bookmark" }, null);
272
+
273
+ const capture = new ConsoleCapture();
274
+ try {
275
+ const response = await fetch(`${baseUrl}/feeds/feed-hash`);
276
+ await response.text();
277
+
278
+ expect(response.status).toBe(200);
279
+ expect(capture.lines).toHaveLength(0);
280
+ } finally {
281
+ capture.restore();
282
+ }
283
+ });
284
+ });
@@ -0,0 +1,147 @@
1
+ import { Trace, Tracer } from "jinaga";
2
+ import { Pool } from "pg";
3
+
4
+ import { JinagaServer } from "../../src/jinaga-server";
5
+
6
+ jest.mock("pg", () => {
7
+ const actual = jest.requireActual("pg");
8
+ const createdPools: any[] = [];
9
+ class CapturingPool extends actual.Pool {
10
+ constructor(options: any) {
11
+ super(options);
12
+ createdPools.push(this);
13
+ }
14
+ }
15
+ return Object.assign({}, actual, { Pool: CapturingPool, createdPools });
16
+ });
17
+
18
+ const { createdPools } = jest.requireMock("pg") as { createdPools: Pool[] };
19
+
20
+ const connectionString = "postgresql://appuser:apppw@localhost:5432/appdb";
21
+
22
+ const poolVariables = [
23
+ "POSTGRES_POOL_MAX",
24
+ "POSTGRES_IDLE_TIMEOUT_MILLIS",
25
+ "POSTGRES_CONNECTION_TIMEOUT_MILLIS"
26
+ ];
27
+
28
+ class CapturingTracer implements Tracer {
29
+ public readonly infoMessages: string[] = [];
30
+
31
+ info(message: string): void {
32
+ this.infoMessages.push(message);
33
+ }
34
+ warn(message: string): void { }
35
+ error(error: any): void { }
36
+ dependency<T>(name: string, data: string, operation: () => Promise<T>): Promise<T> {
37
+ return operation();
38
+ }
39
+ metric(message: string, measurements: { [key: string]: number; }): void { }
40
+ counter(name: string, value: number): void { }
41
+ }
42
+
43
+ describe("Postgres pool configuration", () => {
44
+ const savedVariables: { [name: string]: string | undefined } = {};
45
+ let previousTracer: Tracer;
46
+ let tracer: CapturingTracer;
47
+
48
+ beforeEach(() => {
49
+ for (const name of poolVariables) {
50
+ savedVariables[name] = process.env[name];
51
+ delete process.env[name];
52
+ }
53
+ createdPools.length = 0;
54
+ previousTracer = Trace.getTracer();
55
+ tracer = new CapturingTracer();
56
+ Trace.configure(tracer);
57
+ });
58
+
59
+ afterEach(() => {
60
+ for (const name of poolVariables) {
61
+ const saved = savedVariables[name];
62
+ if (saved === undefined) {
63
+ delete process.env[name];
64
+ }
65
+ else {
66
+ process.env[name] = saved;
67
+ }
68
+ }
69
+ Trace.configure(previousTracer);
70
+ });
71
+
72
+ // Reads the options of the pool that create built. It never opens a
73
+ // connection, because pg defers connecting until a client is checked out.
74
+ function optionsOfPoolBuiltFromConnectionString(): any {
75
+ JinagaServer.create({ pgStore: connectionString });
76
+ expect(createdPools).toHaveLength(1);
77
+ return (createdPools[0] as any).options;
78
+ }
79
+
80
+ it("defaults to ten connections, a thirty second idle timeout, and no checkout timeout", () => {
81
+ const options = optionsOfPoolBuiltFromConnectionString();
82
+
83
+ expect(options.max).toBe(10);
84
+ expect(options.idleTimeoutMillis).toBe(30000);
85
+ expect(options.connectionTimeoutMillis).toBe(0);
86
+ });
87
+
88
+ it("takes the pool size from POSTGRES_POOL_MAX", () => {
89
+ process.env.POSTGRES_POOL_MAX = "40";
90
+
91
+ expect(optionsOfPoolBuiltFromConnectionString().max).toBe(40);
92
+ });
93
+
94
+ it("takes the checkout timeout from POSTGRES_CONNECTION_TIMEOUT_MILLIS", () => {
95
+ process.env.POSTGRES_CONNECTION_TIMEOUT_MILLIS = "5000";
96
+
97
+ expect(optionsOfPoolBuiltFromConnectionString().connectionTimeoutMillis).toBe(5000);
98
+ });
99
+
100
+ it("takes the idle timeout from POSTGRES_IDLE_TIMEOUT_MILLIS", () => {
101
+ process.env.POSTGRES_IDLE_TIMEOUT_MILLIS = "60000";
102
+
103
+ expect(optionsOfPoolBuiltFromConnectionString().idleTimeoutMillis).toBe(60000);
104
+ });
105
+
106
+ it.each(poolVariables)("refuses a value of %s that is not a non-negative integer", (name) => {
107
+ process.env[name] = "30s";
108
+
109
+ expect(() => JinagaServer.create({ pgStore: connectionString }))
110
+ .toThrow(/POSTGRES_[A-Z_]+.*30s|30s.*POSTGRES_[A-Z_]+/);
111
+ expect(() => JinagaServer.create({ pgStore: connectionString }))
112
+ .toThrow(new RegExp(name));
113
+ });
114
+
115
+ it("refuses a pool of zero connections", () => {
116
+ process.env.POSTGRES_POOL_MAX = "0";
117
+
118
+ expect(() => JinagaServer.create({ pgStore: connectionString }))
119
+ .toThrow(/POSTGRES_POOL_MAX/);
120
+ });
121
+
122
+ it("traces the resolved settings of each pool it builds from a connection string", () => {
123
+ process.env.POSTGRES_POOL_MAX = "40";
124
+ process.env.POSTGRES_IDLE_TIMEOUT_MILLIS = "60000";
125
+ process.env.POSTGRES_CONNECTION_TIMEOUT_MILLIS = "5000";
126
+
127
+ JinagaServer.create({ pgStore: connectionString });
128
+
129
+ const poolMessages = tracer.infoMessages.filter(m => /pool/i.test(m));
130
+ expect(poolMessages).toHaveLength(1);
131
+ expect(poolMessages[0]).toContain("40");
132
+ expect(poolMessages[0]).toContain("60000");
133
+ expect(poolMessages[0]).toContain("5000");
134
+ expect(poolMessages[0]).not.toContain("apppw");
135
+ });
136
+
137
+ it("uses a pool the caller supplies exactly as given", () => {
138
+ process.env.POSTGRES_POOL_MAX = "40";
139
+ const actualPg = jest.requireActual("pg");
140
+ const suppliedPool = new actualPg.Pool({ connectionString, max: 3 });
141
+
142
+ JinagaServer.create({ pgStore: suppliedPool });
143
+
144
+ expect(createdPools).toHaveLength(0);
145
+ expect(suppliedPool.options.max).toBe(3);
146
+ });
147
+ });