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
@@ -0,0 +1,368 @@
1
+ import { EventEmitter } from "events";
2
+ import { Trace, Tracer } from "jinaga";
3
+ import { Pool } from "pg";
4
+
5
+ import { tracePool } from "../../src/jinaga-server";
6
+
7
+ const windowMilliseconds = 10000;
8
+
9
+ // Jest's own fake timers cannot be installed on this version of Node, because
10
+ // they try to replace the read-only global `performance`. These stand in for
11
+ // the interval timer alone, which is all tracePool schedules, and they record
12
+ // whether it was unreferenced and whether it was cleared.
13
+ class FakeInterval {
14
+ public dueAt: number;
15
+ public cleared = false;
16
+ private referenced = true;
17
+
18
+ constructor(
19
+ private readonly callback: () => void,
20
+ public readonly delay: number,
21
+ now: number
22
+ ) {
23
+ this.dueAt = now + delay;
24
+ }
25
+
26
+ ref(): this {
27
+ this.referenced = true;
28
+ return this;
29
+ }
30
+
31
+ unref(): this {
32
+ this.referenced = false;
33
+ return this;
34
+ }
35
+
36
+ hasRef(): boolean {
37
+ return this.referenced;
38
+ }
39
+
40
+ fire() {
41
+ this.callback();
42
+ }
43
+ }
44
+
45
+ class FakeClock {
46
+ private now = 0;
47
+ private readonly created: FakeInterval[] = [];
48
+
49
+ install() {
50
+ jest.spyOn(global, 'setInterval').mockImplementation(((callback: () => void, delay?: number) => {
51
+ const interval = new FakeInterval(callback, delay ?? 0, this.now);
52
+ this.created.push(interval);
53
+ return interval;
54
+ }) as any);
55
+ jest.spyOn(global, 'clearInterval').mockImplementation(((handle: FakeInterval) => {
56
+ handle.cleared = true;
57
+ }) as any);
58
+ }
59
+
60
+ advance(milliseconds: number) {
61
+ const end = this.now + milliseconds;
62
+ for (;;) {
63
+ const due = this.live()
64
+ .filter(interval => interval.dueAt <= end)
65
+ .sort((a, b) => a.dueAt - b.dueAt)[0];
66
+ if (!due) {
67
+ break;
68
+ }
69
+ this.now = due.dueAt;
70
+ due.dueAt = this.now + due.delay;
71
+ due.fire();
72
+ }
73
+ this.now = end;
74
+ }
75
+
76
+ live(): FakeInterval[] {
77
+ return this.created.filter(interval => !interval.cleared);
78
+ }
79
+
80
+ only(): FakeInterval {
81
+ expect(this.created).toHaveLength(1);
82
+ return this.created[0];
83
+ }
84
+ }
85
+
86
+ interface Counts {
87
+ total: number;
88
+ idle: number;
89
+ waiting: number;
90
+ }
91
+
92
+ interface MetricLine {
93
+ message: string;
94
+ measurements: { [key: string]: number };
95
+ }
96
+
97
+ class RecordingTracer implements Tracer {
98
+ public readonly info_: string[] = [];
99
+ public readonly warnings: string[] = [];
100
+ public readonly errors: any[] = [];
101
+ public readonly metrics: MetricLine[] = [];
102
+
103
+ info(message: string): void {
104
+ this.info_.push(message);
105
+ }
106
+
107
+ warn(message: string): void {
108
+ this.warnings.push(message);
109
+ }
110
+
111
+ error(error: any): void {
112
+ this.errors.push(error);
113
+ }
114
+
115
+ dependency<T>(name: string, data: string, operation: () => Promise<T>): Promise<T> {
116
+ return operation();
117
+ }
118
+
119
+ metric(message: string, measurements: { [key: string]: number }): void {
120
+ this.metrics.push({ message, measurements });
121
+ }
122
+
123
+ counter(name: string, value: number): void {
124
+ }
125
+
126
+ linesNamed(message: string): MetricLine[] {
127
+ return this.metrics.filter(m => m.message === message);
128
+ }
129
+ }
130
+
131
+ // Stands in for a pg Pool. It reports the counts a test chooses and emits the
132
+ // events a real pool emits, so tracePool can be driven through a storm without
133
+ // a database.
134
+ class PoolDouble extends EventEmitter {
135
+ public totalCount = 0;
136
+ public idleCount = 0;
137
+ public waitingCount = 0;
138
+ public ended = false;
139
+
140
+ asPool(): Pool {
141
+ return this as unknown as Pool;
142
+ }
143
+
144
+ acquire(counts: Counts) {
145
+ this.report(counts);
146
+ this.emit('acquire', {});
147
+ }
148
+
149
+ release(counts: Counts) {
150
+ this.report(counts);
151
+ this.emit('release', undefined, {});
152
+ }
153
+
154
+ connect(counts: Counts) {
155
+ this.report(counts);
156
+ this.emit('connect', {});
157
+ }
158
+
159
+ remove(counts: Counts) {
160
+ this.report(counts);
161
+ this.emit('remove', {});
162
+ }
163
+
164
+ fail(error: Error) {
165
+ this.emit('error', error, {});
166
+ }
167
+
168
+ private report(counts: Counts) {
169
+ this.totalCount = counts.total;
170
+ this.idleCount = counts.idle;
171
+ this.waitingCount = counts.waiting;
172
+ }
173
+ }
174
+
175
+ describe('tracePool', () => {
176
+ let tracer: RecordingTracer;
177
+ let pool: PoolDouble;
178
+ let clock: FakeClock;
179
+
180
+ beforeEach(() => {
181
+ clock = new FakeClock();
182
+ clock.install();
183
+ tracer = new RecordingTracer();
184
+ Trace.configure(tracer);
185
+ pool = new PoolDouble();
186
+ });
187
+
188
+ afterEach(() => {
189
+ jest.restoreAllMocks();
190
+ Trace.off();
191
+ });
192
+
193
+ it('should write one line for a window of a thousand acquires', () => {
194
+ tracePool(pool.asPool());
195
+
196
+ for (let i = 0; i < 1000; i++) {
197
+ pool.acquire({ total: 10, idle: 0, waiting: 0 });
198
+ pool.release({ total: 10, idle: 1, waiting: 0 });
199
+ }
200
+
201
+ expect(tracer.metrics).toHaveLength(0);
202
+
203
+ clock.advance(windowMilliseconds);
204
+
205
+ expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
206
+ expect(tracer.metrics).toHaveLength(1);
207
+ });
208
+
209
+ it('should write no line per acquire', () => {
210
+ tracePool(pool.asPool());
211
+
212
+ pool.acquire({ total: 10, idle: 0, waiting: 0 });
213
+ pool.acquire({ total: 10, idle: 0, waiting: 1 });
214
+
215
+ expect(tracer.metrics).toHaveLength(0);
216
+ expect(tracer.linesNamed('Postgres acquired')).toHaveLength(0);
217
+
218
+ clock.advance(windowMilliseconds);
219
+
220
+ expect(tracer.linesNamed('Postgres acquired')).toHaveLength(0);
221
+ });
222
+
223
+ it('should carry the peak waiting count of a spike that came and went', () => {
224
+ tracePool(pool.asPool());
225
+
226
+ pool.acquire({ total: 10, idle: 0, waiting: 0 });
227
+ pool.acquire({ total: 10, idle: 0, waiting: 349 });
228
+ pool.release({ total: 10, idle: 0, waiting: 120 });
229
+ pool.release({ total: 10, idle: 3, waiting: 0 });
230
+
231
+ clock.advance(windowMilliseconds);
232
+
233
+ const lines = tracer.linesNamed('Postgres pool');
234
+ expect(lines).toHaveLength(1);
235
+ expect(lines[0].measurements).toEqual({
236
+ total: 10,
237
+ idle: 3,
238
+ waiting: 0,
239
+ peakWaiting: 349
240
+ });
241
+ });
242
+
243
+ it('should warn about a window whose peak waiting count was above zero', () => {
244
+ tracePool(pool.asPool());
245
+
246
+ pool.acquire({ total: 10, idle: 0, waiting: 349 });
247
+ pool.release({ total: 10, idle: 3, waiting: 0 });
248
+
249
+ clock.advance(windowMilliseconds);
250
+
251
+ expect(tracer.warnings).toHaveLength(1);
252
+ expect(tracer.warnings[0]).toContain('349');
253
+ });
254
+
255
+ it('should not warn about a window that never had a request waiting', () => {
256
+ tracePool(pool.asPool());
257
+
258
+ pool.acquire({ total: 10, idle: 2, waiting: 0 });
259
+
260
+ clock.advance(windowMilliseconds);
261
+
262
+ expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
263
+ expect(tracer.warnings).toHaveLength(0);
264
+ });
265
+
266
+ it('should write nothing for a window with no acquires', () => {
267
+ tracePool(pool.asPool());
268
+
269
+ clock.advance(windowMilliseconds * 3);
270
+
271
+ expect(tracer.metrics).toHaveLength(0);
272
+ expect(tracer.warnings).toHaveLength(0);
273
+ });
274
+
275
+ it('should write nothing for a window in which only a release happened', () => {
276
+ tracePool(pool.asPool());
277
+
278
+ pool.release({ total: 10, idle: 10, waiting: 0 });
279
+
280
+ clock.advance(windowMilliseconds);
281
+
282
+ expect(tracer.metrics).toHaveLength(0);
283
+ });
284
+
285
+ it('should report a storm that stopped in the middle of a window', () => {
286
+ tracePool(pool.asPool());
287
+
288
+ for (let i = 0; i < 200; i++) {
289
+ pool.acquire({ total: 10, idle: 0, waiting: 349 });
290
+ }
291
+ pool.release({ total: 10, idle: 0, waiting: 0 });
292
+
293
+ // The storm is over, and nothing touches the pool again. Only the
294
+ // window's own close can report it.
295
+ clock.advance(windowMilliseconds);
296
+
297
+ const lines = tracer.linesNamed('Postgres pool');
298
+ expect(lines).toHaveLength(1);
299
+ expect(lines[0].measurements.peakWaiting).toBe(349);
300
+
301
+ clock.advance(windowMilliseconds * 2);
302
+
303
+ expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
304
+ });
305
+
306
+ it('should start each window with a fresh peak', () => {
307
+ tracePool(pool.asPool());
308
+
309
+ pool.acquire({ total: 10, idle: 0, waiting: 349 });
310
+ clock.advance(windowMilliseconds);
311
+
312
+ pool.acquire({ total: 10, idle: 4, waiting: 0 });
313
+ clock.advance(windowMilliseconds);
314
+
315
+ const lines = tracer.linesNamed('Postgres pool');
316
+ expect(lines).toHaveLength(2);
317
+ expect(lines[0].measurements.peakWaiting).toBe(349);
318
+ expect(lines[1].measurements.peakWaiting).toBe(0);
319
+ expect(tracer.warnings).toHaveLength(1);
320
+ });
321
+
322
+ it('should not hold the process open with the timer it schedules', () => {
323
+ tracePool(pool.asPool());
324
+
325
+ expect(clock.only().hasRef()).toBe(false);
326
+ });
327
+
328
+ it('should stop reporting once the pool has ended', () => {
329
+ tracePool(pool.asPool());
330
+
331
+ pool.acquire({ total: 10, idle: 0, waiting: 0 });
332
+ clock.advance(windowMilliseconds);
333
+ expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
334
+
335
+ pool.acquire({ total: 10, idle: 0, waiting: 0 });
336
+ pool.ended = true;
337
+
338
+ clock.advance(windowMilliseconds * 5);
339
+
340
+ expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
341
+ expect(clock.live()).toHaveLength(0);
342
+ });
343
+
344
+ it('should still report each physical connection as it is made and removed', () => {
345
+ tracePool(pool.asPool());
346
+
347
+ pool.connect({ total: 1, idle: 0, waiting: 0 });
348
+ pool.remove({ total: 0, idle: 0, waiting: 0 });
349
+
350
+ expect(tracer.linesNamed('Postgres connected')).toEqual([{
351
+ message: 'Postgres connected',
352
+ measurements: { total: 1, idle: 0, waiting: 0 }
353
+ }]);
354
+ expect(tracer.linesNamed('Postgres disconnected')).toEqual([{
355
+ message: 'Postgres disconnected',
356
+ measurements: { total: 0, idle: 0, waiting: 0 }
357
+ }]);
358
+ });
359
+
360
+ it('should still report a pool error', () => {
361
+ tracePool(pool.asPool());
362
+
363
+ const error = new Error('connection terminated unexpectedly');
364
+ pool.fail(error);
365
+
366
+ expect(tracer.errors).toEqual([error]);
367
+ });
368
+ });
@@ -1,6 +1,6 @@
1
1
  import { Specification } from "jinaga";
2
2
  import { ensureGetFactTypeId, FactTypeMap, getRoleId } from "../../src/postgres/maps";
3
- import { purgeSqlFromSpecification } from "../../src/postgres/purge-sql";
3
+ import { purgeSqlFromSpecification, purgeSqlFromSpecifications } from "../../src/postgres/purge-sql";
4
4
  import { model, Site, SiteDeleted, SitePurged } from "../models/blog";
5
5
 
6
6
  describe("Purge SQL", () => {
@@ -21,7 +21,7 @@ describe("Purge SQL", () => {
21
21
  const { sql, parameters } = whenGeneratePurgeSql(specification, factTypes, roleMap, schema);
22
22
 
23
23
  const expected =
24
- `WITH candidates AS (
24
+ `WITH candidates1 AS (
25
25
  SELECT
26
26
  f1.fact_id as purge_root,
27
27
  f2.fact_id as trigger1
@@ -32,20 +32,24 @@ describe("Purge SQL", () => {
32
32
  JOIN public.fact f2
33
33
  ON f2.fact_id = e1.successor_fact_id
34
34
  WHERE f1.fact_type_id = $1
35
+ ), roots AS (
36
+ SELECT purge_root FROM candidates1
37
+ ), triggers AS (
38
+ SELECT trigger1 AS fact_id FROM candidates1
35
39
  ), targets AS (
36
40
  SELECT a.fact_id
37
41
  FROM public.ancestor a
38
- JOIN candidates c ON c.purge_root = a.ancestor_fact_id
42
+ JOIN roots r ON r.purge_root = a.ancestor_fact_id
39
43
  WHERE NOT EXISTS (
40
44
  SELECT 1
41
- FROM candidates c2
42
- WHERE a.fact_id = c2.trigger1
45
+ FROM triggers t
46
+ WHERE a.fact_id = t.fact_id
43
47
  )
44
48
  AND NOT EXISTS (
45
49
  SELECT 1
46
- FROM candidates c2
50
+ FROM triggers t
47
51
  JOIN public.ancestor a2
48
- ON a2.fact_id = c2.trigger1
52
+ ON a2.fact_id = t.fact_id
49
53
  WHERE a.fact_id = a2.ancestor_fact_id
50
54
  )
51
55
  ), facts AS (
@@ -83,7 +87,7 @@ SELECT fact_id FROM facts
83
87
  const { sql, parameters } = whenGeneratePurgeSql(specification, factTypes, roleMap, schema);
84
88
 
85
89
  const expected =
86
- `WITH candidates AS (
90
+ `WITH candidates1 AS (
87
91
  SELECT
88
92
  f1.fact_id as purge_root,
89
93
  f3.fact_id as trigger1
@@ -99,20 +103,24 @@ SELECT fact_id FROM facts
99
103
  JOIN public.fact f3
100
104
  ON f3.fact_id = e2.successor_fact_id
101
105
  WHERE f1.fact_type_id = $1
106
+ ), roots AS (
107
+ SELECT purge_root FROM candidates1
108
+ ), triggers AS (
109
+ SELECT trigger1 AS fact_id FROM candidates1
102
110
  ), targets AS (
103
111
  SELECT a.fact_id
104
112
  FROM public.ancestor a
105
- JOIN candidates c ON c.purge_root = a.ancestor_fact_id
113
+ JOIN roots r ON r.purge_root = a.ancestor_fact_id
106
114
  WHERE NOT EXISTS (
107
115
  SELECT 1
108
- FROM candidates c2
109
- WHERE a.fact_id = c2.trigger1
116
+ FROM triggers t
117
+ WHERE a.fact_id = t.fact_id
110
118
  )
111
119
  AND NOT EXISTS (
112
120
  SELECT 1
113
- FROM candidates c2
121
+ FROM triggers t
114
122
  JOIN public.ancestor a2
115
- ON a2.fact_id = c2.trigger1
123
+ ON a2.fact_id = t.fact_id
116
124
  WHERE a.fact_id = a2.ancestor_fact_id
117
125
  )
118
126
  ), facts AS (
@@ -154,7 +162,7 @@ SELECT fact_id FROM facts
154
162
  const { sql, parameters } = whenGeneratePurgeSql(specification, factTypes, roleMap, schema);
155
163
 
156
164
  const expected =
157
- `WITH candidates AS (
165
+ `WITH candidates1 AS (
158
166
  SELECT
159
167
  f1.fact_id as purge_root,
160
168
  f2.fact_id as trigger1,
@@ -171,28 +179,119 @@ SELECT fact_id FROM facts
171
179
  JOIN public.fact f3
172
180
  ON f3.fact_id = e2.successor_fact_id
173
181
  WHERE f1.fact_type_id = $1
182
+ ), roots AS (
183
+ SELECT purge_root FROM candidates1
184
+ ), triggers AS (
185
+ SELECT trigger1 AS fact_id FROM candidates1
186
+ UNION
187
+ SELECT trigger2 AS fact_id FROM candidates1
174
188
  ), targets AS (
175
189
  SELECT a.fact_id
176
190
  FROM public.ancestor a
177
- JOIN candidates c ON c.purge_root = a.ancestor_fact_id
191
+ JOIN roots r ON r.purge_root = a.ancestor_fact_id
178
192
  WHERE NOT EXISTS (
179
193
  SELECT 1
180
- FROM candidates c2
181
- WHERE a.fact_id = c2.trigger1
182
- OR a.fact_id = c2.trigger2
194
+ FROM triggers t
195
+ WHERE a.fact_id = t.fact_id
183
196
  )
184
197
  AND NOT EXISTS (
185
198
  SELECT 1
186
- FROM candidates c2
199
+ FROM triggers t
187
200
  JOIN public.ancestor a2
188
- ON a2.fact_id = c2.trigger1
201
+ ON a2.fact_id = t.fact_id
189
202
  WHERE a.fact_id = a2.ancestor_fact_id
190
203
  )
204
+ ), facts AS (
205
+ DELETE
206
+ FROM public.fact f
207
+ USING targets t WHERE t.fact_id = f.fact_id
208
+ RETURNING f.fact_id
209
+ )
210
+ SELECT fact_id FROM facts
211
+ `;
212
+
213
+ expect(sql).toBe(expected);
214
+ expect(parameters).toEqual([
215
+ ensureGetFactTypeId(factTypes, Site.Type),
216
+ getRoleId(roleMap, ensureGetFactTypeId(factTypes, SiteDeleted.Type), 'site'),
217
+ getRoleId(roleMap, ensureGetFactTypeId(factTypes, SitePurged.Type), 'deleted')
218
+ ]);
219
+ });
220
+ it("should spare the triggers of every condition on one given type", () => {
221
+ const deletedSpecification = model.given(Site).match((site, facts) =>
222
+ facts.ofType(SiteDeleted)
223
+ .join(deleted => deleted.site, site)
224
+ ).specification;
225
+ const purgedSpecification = model.given(Site).match((site, facts) =>
226
+ facts.ofType(SitePurged)
227
+ .join(purged => purged.deleted.site, site)
228
+ ).specification;
229
+ const factTypes = buildFactTypeMap(
230
+ Site.Type,
231
+ SiteDeleted.Type,
232
+ SitePurged.Type
233
+ );
234
+ const roleMap = buildRoleMap(factTypes,
235
+ [Site.Type, ['creator']],
236
+ [SiteDeleted.Type, ['site']],
237
+ [SitePurged.Type, ['deleted']]
238
+ );
239
+ const purgeCommand = purgeSqlFromSpecifications([deletedSpecification, purgedSpecification], factTypes, roleMap, 'public');
240
+ if (purgeCommand === null) {
241
+ throw new Error("The specifications were not satisfiable.");
242
+ }
243
+ const { sql, parameters } = purgeCommand;
244
+
245
+ const expected =
246
+ `WITH candidates1 AS (
247
+ SELECT
248
+ f1.fact_id as purge_root,
249
+ f2.fact_id as trigger1
250
+ FROM public.fact f1
251
+ JOIN public.edge e1
252
+ ON e1.predecessor_fact_id = f1.fact_id
253
+ AND e1.role_id = $2
254
+ JOIN public.fact f2
255
+ ON f2.fact_id = e1.successor_fact_id
256
+ WHERE f1.fact_type_id = $1
257
+ ), candidates2 AS (
258
+ SELECT
259
+ f1.fact_id as purge_root,
260
+ f3.fact_id as trigger1
261
+ FROM public.fact f1
262
+ JOIN public.edge e1
263
+ ON e1.predecessor_fact_id = f1.fact_id
264
+ AND e1.role_id = $4
265
+ JOIN public.fact f2
266
+ ON f2.fact_id = e1.successor_fact_id
267
+ JOIN public.edge e2
268
+ ON e2.predecessor_fact_id = f2.fact_id
269
+ AND e2.role_id = $5
270
+ JOIN public.fact f3
271
+ ON f3.fact_id = e2.successor_fact_id
272
+ WHERE f1.fact_type_id = $3
273
+ ), roots AS (
274
+ SELECT purge_root FROM candidates1
275
+ UNION
276
+ SELECT purge_root FROM candidates2
277
+ ), triggers AS (
278
+ SELECT trigger1 AS fact_id FROM candidates1
279
+ UNION
280
+ SELECT trigger1 AS fact_id FROM candidates2
281
+ ), targets AS (
282
+ SELECT a.fact_id
283
+ FROM public.ancestor a
284
+ JOIN roots r ON r.purge_root = a.ancestor_fact_id
285
+ WHERE NOT EXISTS (
286
+ SELECT 1
287
+ FROM triggers t
288
+ WHERE a.fact_id = t.fact_id
289
+ )
191
290
  AND NOT EXISTS (
192
291
  SELECT 1
193
- FROM candidates c2
292
+ FROM triggers t
194
293
  JOIN public.ancestor a2
195
- ON a2.fact_id = c2.trigger2
294
+ ON a2.fact_id = t.fact_id
196
295
  WHERE a.fact_id = a2.ancestor_fact_id
197
296
  )
198
297
  ), facts AS (
@@ -206,6 +305,8 @@ SELECT fact_id FROM facts
206
305
 
207
306
  expect(sql).toBe(expected);
208
307
  expect(parameters).toEqual([
308
+ ensureGetFactTypeId(factTypes, Site.Type),
309
+ getRoleId(roleMap, ensureGetFactTypeId(factTypes, SiteDeleted.Type), 'site'),
209
310
  ensureGetFactTypeId(factTypes, Site.Type),
210
311
  getRoleId(roleMap, ensureGetFactTypeId(factTypes, SiteDeleted.Type), 'site'),
211
312
  getRoleId(roleMap, ensureGetFactTypeId(factTypes, SitePurged.Type), 'deleted')