jinaga-server 3.7.5 → 3.8.1

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 (48) 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 +19 -23
  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/dist/postgres/postgres-store.js +18 -4
  31. package/dist/postgres/postgres-store.js.map +1 -1
  32. package/dist/postgres/purge-sql.d.ts +10 -0
  33. package/dist/postgres/purge-sql.js +77 -50
  34. package/dist/postgres/purge-sql.js.map +1 -1
  35. package/integration-test/jinaga-test/package-lock.json +4 -4
  36. package/integration-test/jinaga-test/package.json +1 -1
  37. package/integration-test/jinaga-test/postgres-keystore-race.test.js +9 -7
  38. package/integration-test/jinaga-test/purge.test.js +52 -1
  39. package/package.json +2 -2
  40. package/src/http/router.ts +31 -39
  41. package/src/http/stream.ts +21 -36
  42. package/src/jinaga-server.ts +92 -4
  43. package/src/postgres/postgres-store.ts +19 -5
  44. package/src/postgres/purge-sql.ts +89 -55
  45. package/test/http/streamLogVolumeSpec.ts +284 -0
  46. package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
  47. package/test/jinaga-server/tracePoolSpec.ts +368 -0
  48. package/test/postgres/purgeSqlSpec.ts +123 -22
@@ -41,6 +41,13 @@ import { outputReadResultsStreaming } from "./output-formatters";
41
41
  import { arrayToResultStream, ResultStream } from "./result-stream";
42
42
  import { Stream } from "./stream";
43
43
 
44
+ // A streaming response reports itself twice: one line when the stream opens
45
+ // and one when it ends, carrying the messages sent, how long it lasted, and
46
+ // what ended it. Issue #192: the per-message and per-lifecycle-step lines this
47
+ // replaces made the log grow with traffic rather than with the number of events
48
+ // worth knowing about, and `STREAM DONE`'s message count already said what the
49
+ // per-message lines said one at a time. A plain GET says nothing at all, since
50
+ // Trace.dependency already records it. Failures still log.
44
51
  function getOrStream<U>(
45
52
  getMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<U | null>),
46
53
  streamMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<Stream<U> | null>)
@@ -49,75 +56,68 @@ function getOrStream<U>(
49
56
  const connectionId = Math.random().toString(36).substring(2, 10);
50
57
  const user = <RequestUser>(req as any).user;
51
58
  const accept = req.headers["accept"];
52
-
53
- console.log(`[HttpConnection:${connectionId}] New request - User: ${user?.id || 'anonymous'}, Accept: ${accept}, URL: ${req.url}`);
54
-
59
+
55
60
  if (accept && accept.indexOf("application/x-jinaga-feed-stream") >= 0) {
56
- console.log(`[HttpConnection:${connectionId}] STREAMING request detected`);
57
-
58
61
  streamMethod(user, req.params, req.query)
59
62
  .then(response => {
60
63
  if (!response) {
61
- console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
62
64
  sendRouteMiss(res);
63
65
  next();
64
66
  }
65
67
  else {
66
- console.log(`[HttpConnection:${connectionId}] Setting up streaming response`);
67
-
68
68
  res.type("application/x-jinaga-feed-stream");
69
69
  res.set("Connection", "keep-alive");
70
70
  res.set("Cache-Control", "no-cache");
71
71
  res.set("Access-Control-Allow-Origin", "*");
72
72
  res.flushHeaders();
73
-
74
- console.log(`[HttpConnection:${connectionId}] Headers flushed - setting up event handlers`);
75
-
73
+
74
+ const openedAt = Date.now();
75
+ console.log(`[HttpConnection:${connectionId}] Stream opened - User: ${user?.id || 'anonymous'}, URL: ${req.url}`);
76
+
76
77
  let clientDisconnected = false;
77
- let timeoutTriggered = false;
78
78
  let streamClosed = false;
79
-
79
+ // What closed the stream from outside, where something
80
+ // other than the feed itself closed it. One value
81
+ // rather than a flag per cause, because a stream ends
82
+ // exactly once and only the first cause is the reason.
83
+ let closedBy: "client disconnect" | "timeout" | null = null;
84
+
80
85
  // Client disconnect handler
81
86
  req.on("close", () => {
82
- console.log(`[HttpConnection:${connectionId}] CLIENT DISCONNECTED`);
83
87
  clientDisconnected = true;
84
88
  if (!streamClosed) {
85
- console.log(`[HttpConnection:${connectionId}] Closing stream due to client disconnect`);
86
- response.close();
89
+ closedBy = "client disconnect";
87
90
  streamClosed = true;
91
+ response.close();
88
92
  }
89
93
  });
90
-
94
+
91
95
  // Timeout handler
92
96
  const timeout = setTimeout(() => {
93
- console.log(`[HttpConnection:${connectionId}] TIMEOUT TRIGGERED (5 minutes)`);
94
- timeoutTriggered = true;
95
97
  if (!streamClosed) {
96
- console.log(`[HttpConnection:${connectionId}] Closing stream due to timeout`);
97
- response.close();
98
+ closedBy = "timeout";
98
99
  streamClosed = true;
100
+ response.close();
99
101
  }
100
102
  }, 5 * 60 * 1000);
101
-
103
+
102
104
  let messageCount = 0;
103
-
105
+
104
106
  response
105
107
  .next(data => {
106
108
  messageCount++;
107
109
  const writeStart = Date.now();
108
-
110
+
109
111
  if (clientDisconnected) {
110
112
  console.warn(`[HttpConnection:${connectionId}] Attempted to write to disconnected client - Message #${messageCount}`);
111
113
  return;
112
114
  }
113
-
115
+
114
116
  try {
115
117
  const jsonData = JSON.stringify(data) + "\n\n";
116
118
  res.write(jsonData);
117
119
  const writeDuration = Date.now() - writeStart;
118
-
119
- console.log(`[HttpConnection:${connectionId}] Message #${messageCount} sent - Size: ${jsonData.length} bytes, Duration: ${writeDuration}ms`);
120
-
120
+
121
121
  if (writeDuration > 50) {
122
122
  console.warn(`[HttpConnection:${connectionId}] SLOW write operation - Duration: ${writeDuration}ms`);
123
123
  }
@@ -126,24 +126,19 @@ function getOrStream<U>(
126
126
  }
127
127
  })
128
128
  .done(() => {
129
- console.log(`[HttpConnection:${connectionId}] STREAM DONE - Messages sent: ${messageCount}, Client disconnected: ${clientDisconnected}, Timeout triggered: ${timeoutTriggered}`);
130
-
131
129
  clearTimeout(timeout);
132
130
  streamClosed = true;
133
-
131
+
132
+ console.log(`[HttpConnection:${connectionId}] Stream ended - Messages sent: ${messageCount}, Duration: ${Date.now() - openedAt}ms, Reason: ${closedBy ?? "done"}`);
133
+
134
134
  try {
135
135
  if (!clientDisconnected) {
136
- console.log(`[HttpConnection:${connectionId}] Ending socket connection`);
137
136
  res.socket?.end();
138
- } else {
139
- console.log(`[HttpConnection:${connectionId}] Client already disconnected - skipping socket end`);
140
137
  }
141
138
  } catch (error) {
142
139
  console.error(`[HttpConnection:${connectionId}] ERROR ending socket: ${error}`);
143
140
  }
144
141
  });
145
-
146
- console.log(`[HttpConnection:${connectionId}] Stream handlers configured`);
147
142
  }
148
143
  })
149
144
  .catch(error => {
@@ -152,16 +147,13 @@ function getOrStream<U>(
152
147
  });
153
148
  }
154
149
  else {
155
- console.log(`[HttpConnection:${connectionId}] Regular GET request`);
156
150
  getMethod(user, req.params, req.query)
157
151
  .then(response => {
158
152
  if (!response) {
159
- console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
160
153
  sendRouteMiss(res);
161
154
  next();
162
155
  }
163
156
  else {
164
- console.log(`[HttpConnection:${connectionId}] Sending JSON response`);
165
157
  res.type("json");
166
158
  res.send(JSON.stringify(response));
167
159
  next();
@@ -1,14 +1,18 @@
1
+ // This class logs nothing on its success path. A stream's lifetime is reported
2
+ // once by the request that opened it (see getOrStream in router.ts), and a line
3
+ // per feed, per replayed item, or per done handler says the same thing again
4
+ // once per unit of traffic. Issue #192: those per-item lines were 11.9% of a
5
+ // 2.8-million-line production log. Failures still log, because a failed handler
6
+ // is an event an operator cannot reconstruct from the summary.
1
7
  export class Stream<T> {
2
8
  private queue: T[] = [];
3
9
  private handlers: ((data: T) => void)[] = [];
4
10
  private doneHandlers: (() => void)[] = [];
5
11
  private closed = false;
6
12
  private readonly streamId: string;
7
- private feedCount = 0;
8
13
 
9
14
  constructor() {
10
15
  this.streamId = Math.random().toString(36).substring(2, 10);
11
- console.log(`[Stream:${this.streamId}] Created new stream`);
12
16
  }
13
17
 
14
18
  next(handler: (data: T) => void): Stream<T> {
@@ -16,21 +20,18 @@ export class Stream<T> {
16
20
  console.warn(`[Stream:${this.streamId}] Attempted to add handler to closed stream`);
17
21
  return this;
18
22
  }
19
-
23
+
20
24
  this.handlers.push(handler);
21
- const queueLength = this.queue.length;
22
- console.log(`[Stream:${this.streamId}] Added handler - Total handlers: ${this.handlers.length}, Queued items: ${queueLength}`);
23
-
25
+
24
26
  // Replay queued data to new handler
25
27
  this.queue.forEach((data, index) => {
26
28
  try {
27
29
  handler(data);
28
- console.log(`[Stream:${this.streamId}] Replayed queued item ${index + 1}/${queueLength} to new handler`);
29
30
  } catch (error) {
30
31
  console.error(`[Stream:${this.streamId}] Error replaying queued item ${index + 1}: ${error}`);
31
32
  }
32
33
  });
33
-
34
+
34
35
  return this;
35
36
  }
36
37
 
@@ -44,9 +45,8 @@ export class Stream<T> {
44
45
  }
45
46
  return this;
46
47
  }
47
-
48
+
48
49
  this.doneHandlers.push(handler);
49
- console.log(`[Stream:${this.streamId}] Added done handler - Total done handlers: ${this.doneHandlers.length}`);
50
50
  return this;
51
51
  }
52
52
 
@@ -55,70 +55,55 @@ export class Stream<T> {
55
55
  console.warn(`[Stream:${this.streamId}] Attempted to close already closed stream`);
56
56
  return;
57
57
  }
58
-
59
- console.log(`[Stream:${this.streamId}] CLOSING stream - Handlers: ${this.handlers.length}, Done handlers: ${this.doneHandlers.length}, Queue: ${this.queue.length}, Feed count: ${this.feedCount}`);
60
-
58
+
61
59
  this.closed = true;
62
-
60
+
63
61
  // Execute done handlers
64
- const doneHandlerCount = this.doneHandlers.length;
65
62
  this.doneHandlers.forEach((handler, index) => {
66
63
  try {
67
64
  handler();
68
- console.log(`[Stream:${this.streamId}] Executed done handler ${index + 1}/${doneHandlerCount}`);
69
65
  } catch (error) {
70
66
  console.error(`[Stream:${this.streamId}] Error in done handler ${index + 1}: ${error}`);
71
67
  }
72
68
  });
73
-
69
+
74
70
  // Clear all references
75
71
  this.queue = [];
76
72
  this.handlers = [];
77
73
  this.doneHandlers = [];
78
-
79
- console.log(`[Stream:${this.streamId}] Stream closed and cleaned up`);
80
74
  }
81
75
 
82
76
  feed(data: any): void {
83
77
  const feedStart = Date.now();
84
-
78
+
85
79
  if (this.closed) {
86
80
  console.warn(`[Stream:${this.streamId}] Attempted to feed data to closed stream - Data type: ${typeof data}`);
87
81
  return;
88
82
  }
89
-
90
- this.feedCount++;
83
+
91
84
  const handlerCount = this.handlers.length;
92
-
93
- console.log(`[Stream:${this.streamId}] FEEDING data - Feed #${this.feedCount}, Handlers: ${handlerCount}, Queue size before: ${this.queue.length}`);
94
-
85
+
95
86
  this.queue.push(data);
96
-
87
+
97
88
  // Notify all handlers
98
- let successCount = 0;
99
- let errorCount = 0;
100
-
101
89
  this.handlers.forEach((handler, index) => {
102
90
  try {
103
91
  const handlerStart = Date.now();
104
92
  handler(data);
105
93
  const handlerDuration = Date.now() - handlerStart;
106
- successCount++;
107
-
94
+
108
95
  if (handlerDuration > 50) {
109
96
  console.warn(`[Stream:${this.streamId}] SLOW handler - Handler ${index + 1}/${handlerCount}, Duration: ${handlerDuration}ms`);
110
97
  }
111
98
  } catch (error) {
112
- errorCount++;
113
99
  console.error(`[Stream:${this.streamId}] ERROR in handler ${index + 1}/${handlerCount}: ${error}`);
114
100
  }
115
101
  });
116
-
102
+
117
103
  const totalDuration = Date.now() - feedStart;
118
- console.log(`[Stream:${this.streamId}] Feed complete - Success: ${successCount}, Errors: ${errorCount}, Queue size after: ${this.queue.length}, Duration: ${totalDuration}ms`);
119
-
104
+
120
105
  if (totalDuration > 100) {
121
106
  console.warn(`[Stream:${this.streamId}] SLOW feed operation - Duration: ${totalDuration}ms`);
122
107
  }
123
108
  }
124
- }
109
+ }
@@ -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,
@@ -44,7 +44,7 @@ import {
44
44
  } from "./maps";
45
45
  import { ResultSetFact, ResultSetRow, ResultSetTree, resultSqlFromSpecification, SqlQueryTree } from "./specification-result-sql";
46
46
  import { sqlFromFeed } from "./specification-sql";
47
- import { purgeDescendantsSql, purgeSqlFromSpecification } from "./purge-sql";
47
+ import { purgeDescendantsSql, purgeSqlFromSpecifications } from "./purge-sql";
48
48
 
49
49
  interface PostgresFactRecord extends FactRecord {
50
50
  timestamp: Date; // Maps to date_learned column
@@ -451,12 +451,26 @@ export class PostgresStore implements Storage {
451
451
  }
452
452
 
453
453
  async purge(purgeConditions: Specification[]): Promise<number> {
454
- let count = 0;
454
+ // Purge all conditions on one root type in a single statement,
455
+ // so that no condition deletes the triggers of another.
456
+ const conditionsByGivenType = new Map<string, Specification[]>();
455
457
  for (const specification of purgeConditions) {
456
- const factTypes = await this.loadFactTypesFromSpecification(specification);
457
- const roleMap = await this.loadRolesFromSpecification(specification, factTypes);
458
+ const givenType = specification.given[0].label.type;
459
+ const group = conditionsByGivenType.get(givenType) ?? [];
460
+ group.push(specification);
461
+ conditionsByGivenType.set(givenType, group);
462
+ }
463
+
464
+ let count = 0;
465
+ for (const specifications of conditionsByGivenType.values()) {
466
+ let factTypes = this.factTypeMap;
467
+ let roleMap = this.roleMap;
468
+ for (const specification of specifications) {
469
+ factTypes = await this.loadFactTypesFromSpecification(specification);
470
+ roleMap = await this.loadRolesFromSpecification(specification, factTypes);
471
+ }
458
472
 
459
- const purgeCommand = purgeSqlFromSpecification(specification, factTypes, roleMap, this.schema);
473
+ const purgeCommand = purgeSqlFromSpecifications(specifications, factTypes, roleMap, this.schema);
460
474
  if (!purgeCommand) {
461
475
  continue;
462
476
  }
@@ -43,6 +43,29 @@ export function purgeDescendantsSql(triggerCount: number, schema: string): strin
43
43
 
44
44
  export function purgeSqlFromSpecification(specification: Specification, factTypes: FactTypeMap, roleMap: RoleMap, schema: string):
45
45
  { sql: string, parameters: (string | number)[] } | null {
46
+ return purgeSqlFromSpecifications([specification], factTypes, roleMap, schema);
47
+ }
48
+
49
+ /**
50
+ * Generate one statement that purges under every given purge condition at once.
51
+ * The conditions must share a given type. Every trigger of every condition,
52
+ * and every ancestor of those triggers, is spared. Running the conditions one
53
+ * statement at a time would let one condition delete another's triggers.
54
+ */
55
+ export function purgeSqlFromSpecifications(specifications: Specification[], factTypes: FactTypeMap, roleMap: RoleMap, schema: string):
56
+ { sql: string, parameters: (string | number)[] } | null {
57
+ const queryDescriptions = specifications
58
+ .map(specification => purgeQueryDescription(specification, factTypes, roleMap))
59
+ .filter((queryDescription): queryDescription is QueryDescription => queryDescription !== null);
60
+
61
+ if (queryDescriptions.length === 0) {
62
+ return null;
63
+ }
64
+
65
+ return generatePurgeSqlQuery(queryDescriptions, schema);
66
+ }
67
+
68
+ function purgeQueryDescription(specification: Specification, factTypes: FactTypeMap, roleMap: RoleMap): QueryDescription | null {
46
69
  const queryDescriptionBuilder = new QueryDescriptionBuilder(factTypes, roleMap);
47
70
 
48
71
  let queryDescription = QueryDescription.unsatisfiable;
@@ -71,61 +94,56 @@ export function purgeSqlFromSpecification(specification: Specification, factType
71
94
  return null;
72
95
  }
73
96
 
74
- const query = generatePurgeSqlQuery(queryDescription, schema);
75
- return query;
76
- }
77
-
78
-
79
- function generatePurgeSqlQuery(queryDescription: QueryDescription, schema: string):
80
- { sql: string, parameters: (string | number)[] } {
81
97
  if (queryDescription.existentialConditions.length > 0) {
82
98
  throw new Error("Purge conditions should not have existential conditions");
83
99
  }
84
100
 
85
- const columns = queryDescription.outputs
86
- .map((label, index) => `f${label.factIndex}.fact_id as trigger${index + 1}`)
87
- .join(",\n ");
88
- const firstEdge = queryDescription.edges[0];
89
- const predecessorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.predecessorFactIndex);
90
- const successorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.successorFactIndex);
91
- const firstFactIndex = predecessorFact ? predecessorFact.factIndex : successorFact!.factIndex;
92
- const writtenFactIndexes = new Set<number>().add(firstFactIndex);
93
- const joins: string[] = generateJoins(queryDescription.edges, writtenFactIndexes, schema);
94
- const inputWhereClauses = queryDescription.inputs
95
- .map(input => `f${input.factIndex}.fact_type_id = $${input.factTypeParameter}`)
96
- .join(" AND ");
97
- const triggerWhereClauses = queryDescription.outputs
98
- .map((label, index) => `a.fact_id = c2.trigger${index + 1}`)
99
- .join("\n OR ");
100
- const triggerAncestorClauses = queryDescription.outputs
101
- .map((label, index) =>
102
- ` AND NOT EXISTS (\n` +
103
- ` SELECT 1\n` +
104
- ` FROM candidates c2\n` +
105
- ` JOIN ${schema}.ancestor a2\n` +
106
- ` ON a2.fact_id = c2.trigger${index + 1}\n` +
107
- ` WHERE a.fact_id = a2.ancestor_fact_id\n` +
108
- ` )\n`
109
- )
110
- .join("");
101
+ return queryDescription;
102
+ }
103
+
104
+ function generatePurgeSqlQuery(queryDescriptions: QueryDescription[], schema: string):
105
+ { sql: string, parameters: (string | number)[] } {
106
+ const parameters: (string | number)[] = [];
107
+ const candidateCtes: string[] = [];
108
+ const rootSelects: string[] = [];
109
+ const triggerSelects: string[] = [];
110
+
111
+ queryDescriptions.forEach((queryDescription, conditionIndex) => {
112
+ const name = `candidates${conditionIndex + 1}`;
113
+ const offset = parameters.length;
114
+ candidateCtes.push(`${name} AS (\n${generateCandidates(queryDescription, offset, schema)})`);
115
+ // Remove parameter 2, which is the hash of the first fact
116
+ parameters.push(
117
+ queryDescription.parameters[0],
118
+ ...queryDescription.parameters.slice(2)
119
+ );
120
+ rootSelects.push(` SELECT purge_root FROM ${name}\n`);
121
+ queryDescription.outputs.forEach((label, index) => {
122
+ triggerSelects.push(` SELECT trigger${index + 1} AS fact_id FROM ${name}\n`);
123
+ });
124
+ });
111
125
 
112
126
  const sql =
113
- `WITH candidates AS (\n` +
114
- ` SELECT\n` +
115
- ` f${firstFactIndex}.fact_id as purge_root,\n` +
116
- ` ${columns}\n` +
117
- ` FROM ${schema}.fact f${firstFactIndex}\n${joins.join("")}` +
118
- ` WHERE ${inputWhereClauses}\n` +
127
+ `WITH ${candidateCtes.join(", ")}, roots AS (\n` +
128
+ rootSelects.join(` UNION\n`) +
129
+ `), triggers AS (\n` +
130
+ triggerSelects.join(` UNION\n`) +
119
131
  `), targets AS (\n` +
120
132
  ` SELECT a.fact_id\n` +
121
133
  ` FROM ${schema}.ancestor a\n` +
122
- ` JOIN candidates c ON c.purge_root = a.ancestor_fact_id\n` +
134
+ ` JOIN roots r ON r.purge_root = a.ancestor_fact_id\n` +
123
135
  ` WHERE NOT EXISTS (\n` +
124
136
  ` SELECT 1\n` +
125
- ` FROM candidates c2\n` +
126
- ` WHERE ${triggerWhereClauses}\n` +
137
+ ` FROM triggers t\n` +
138
+ ` WHERE a.fact_id = t.fact_id\n` +
139
+ ` )\n` +
140
+ ` AND NOT EXISTS (\n` +
141
+ ` SELECT 1\n` +
142
+ ` FROM triggers t\n` +
143
+ ` JOIN ${schema}.ancestor a2\n` +
144
+ ` ON a2.fact_id = t.fact_id\n` +
145
+ ` WHERE a.fact_id = a2.ancestor_fact_id\n` +
127
146
  ` )\n` +
128
- triggerAncestorClauses +
129
147
  `), facts AS (\n` +
130
148
  ` DELETE\n` +
131
149
  ` FROM ${schema}.fact f\n` +
@@ -133,21 +151,37 @@ function generatePurgeSqlQuery(queryDescription: QueryDescription, schema: strin
133
151
  ` RETURNING f.fact_id\n` +
134
152
  `)\n` +
135
153
  `SELECT fact_id FROM facts\n`;
136
-
137
- // Remove parameter 1, which is the hash of the first fact
138
- const parameters = [
139
- queryDescription.parameters[0],
140
- ...queryDescription.parameters.slice(2)
141
- ];
142
-
143
154
 
144
155
  return {
145
156
  sql,
146
- parameters: parameters
157
+ parameters
147
158
  };
148
159
  }
149
160
 
150
- function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>, schema: string) {
161
+ function generateCandidates(queryDescription: QueryDescription, offset: number, schema: string): string {
162
+ const columns = queryDescription.outputs
163
+ .map((label, index) => `f${label.factIndex}.fact_id as trigger${index + 1}`)
164
+ .join(",\n ");
165
+ const firstEdge = queryDescription.edges[0];
166
+ const predecessorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.predecessorFactIndex);
167
+ const successorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.successorFactIndex);
168
+ const firstFactIndex = predecessorFact ? predecessorFact.factIndex : successorFact!.factIndex;
169
+ const writtenFactIndexes = new Set<number>().add(firstFactIndex);
170
+ const joins: string[] = generateJoins(queryDescription.edges, writtenFactIndexes, offset, schema);
171
+ const inputWhereClauses = queryDescription.inputs
172
+ .map(input => `f${input.factIndex}.fact_type_id = $${input.factTypeParameter + offset}`)
173
+ .join(" AND ");
174
+
175
+ return (
176
+ ` SELECT\n` +
177
+ ` f${firstFactIndex}.fact_id as purge_root,\n` +
178
+ ` ${columns}\n` +
179
+ ` FROM ${schema}.fact f${firstFactIndex}\n${joins.join("")}` +
180
+ ` WHERE ${inputWhereClauses}\n`
181
+ );
182
+ }
183
+
184
+ function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>, offset: number, schema: string) {
151
185
  const joins: string[] = [];
152
186
  edges.forEach(edge => {
153
187
  if (writtenFactIndexes.has(edge.predecessorFactIndex)) {
@@ -156,14 +190,14 @@ function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>
156
190
  ` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
157
191
  ` ON e${edge.edgeIndex}.predecessor_fact_id = f${edge.predecessorFactIndex}.fact_id\n` +
158
192
  ` AND e${edge.edgeIndex}.successor_fact_id = f${edge.successorFactIndex}.fact_id\n` +
159
- ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
193
+ ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
160
194
  );
161
195
  }
162
196
  else {
163
197
  joins.push(
164
198
  ` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
165
199
  ` ON e${edge.edgeIndex}.predecessor_fact_id = f${edge.predecessorFactIndex}.fact_id\n` +
166
- ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
200
+ ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
167
201
  );
168
202
  joins.push(
169
203
  ` JOIN ${schema}.fact f${edge.successorFactIndex}\n` +
@@ -176,7 +210,7 @@ function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>
176
210
  joins.push(
177
211
  ` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
178
212
  ` ON e${edge.edgeIndex}.successor_fact_id = f${edge.successorFactIndex}.fact_id\n` +
179
- ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
213
+ ` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
180
214
  );
181
215
  joins.push(
182
216
  ` JOIN ${schema}.fact f${edge.predecessorFactIndex}\n` +