@stackbone/cli 0.6.1 → 0.6.3

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.
@@ -0,0 +1,14 @@
1
+ -- 0090_trigger_subscriptions_sample_event.sql — the first real event a link received (PLA-1900)
2
+ -- A trigger's declared `sampleData` is the only thing the mapper can derive event fields
3
+ -- from when the trigger publishes no `outputSchema`, and 49 Activepieces polling triggers
4
+ -- declare it as `{}` (Gmail's `gmail_new_email_received` among them). Their links offered
5
+ -- no field to wire. The link now keeps the first real event it delivers, and the mapper
6
+ -- reads that instead.
7
+ --
8
+ -- Written by the intake tick, never by an operator, and only while it is still NULL: the
9
+ -- first event wins and a later one never replaces it. The tick bounds it before writing
10
+ -- (long strings and arrays clipped), because `list()` returns this column for every link.
11
+ -- Nullable: NULL is "no sample yet", which every link that existed before this reads as.
12
+
13
+ ALTER TABLE stackbone_platform.trigger_subscriptions
14
+ ADD COLUMN IF NOT EXISTS sample_event jsonb;
@@ -0,0 +1,32 @@
1
+ -- 0091_echo_guard_checks.sql — the runs that asked the self-write guard (PLA-1580)
2
+ -- A workflow that answers a connector trigger and writes back through the same
3
+ -- connection raises the event that trigger reads, so it starts itself again.
4
+ -- `isOwnEcho` stops that, and until now nothing recorded that a run called it:
5
+ -- the guard is a read, and it left no trace. The box now writes one row here per
6
+ -- run and connector the first time a run asks, whatever the guard answers, so the
7
+ -- link-level warning can leave a guarded run out.
8
+ --
9
+ -- One row per (run, connector), never one per call: the warning asks "did this run
10
+ -- guard this connector", and a run that asks a hundred times is the same answer.
11
+ -- The connector is compared exactly, like every connector id: a guard on `slack`
12
+ -- does not cover `linear`.
13
+ --
14
+ -- ON DELETE CASCADE from the run, the same as `run_steps`: a marker means nothing
15
+ -- once the run it describes is gone.
16
+ CREATE TABLE IF NOT EXISTS stackbone_platform.echo_guard_checks (
17
+ run_id uuid NOT NULL
18
+ REFERENCES stackbone_platform.runs(id) ON DELETE CASCADE,
19
+ connector text NOT NULL,
20
+ created_at timestamptz NOT NULL DEFAULT now(),
21
+ PRIMARY KEY (run_id, connector)
22
+ );
23
+
24
+ -- The warning reads the runs ONE link started or resumed inside a short window,
25
+ -- once per link on every `GET /api/trigger-subscriptions`. 0075 left `trigger_ref`
26
+ -- unindexed because nothing filtered on it; this is that reader. On `started_at`
27
+ -- rather than `created_at` because a resume re-arms the row and moves `started_at`
28
+ -- to the resume, so a run that woke up and wrote is read by when it woke. Partial,
29
+ -- so only `event` runs (the only ones that carry a ref) pay for it on write.
30
+ CREATE INDEX IF NOT EXISTS runs_trigger_ref_started_at_idx
31
+ ON stackbone_platform.runs (trigger_ref, started_at DESC)
32
+ WHERE trigger_ref IS NOT NULL;
@@ -0,0 +1,522 @@
1
+ -- 0092_trigger_subscriptions_outlook_new_email.sql — the `outlook-app-only` links
2
+ -- move to the `microsoft-outlook` piece's `newEmail` (PLA-1932).
3
+ --
4
+ -- Both Outlook built-ins left the catalog: the one `microsoft-outlook` piece takes
5
+ -- either grant (PLA-1838) and its `newEmail` replaces their `message-received`.
6
+ -- Same folder (the Inbox), same `receivedDateTime gt` filter, every sender. What
7
+ -- differs is the event and where the mailbox lives, so each link is rewritten in
8
+ -- place, on the box that holds it, when that box boots: a box on a laptop is
9
+ -- converted by starting it, with nothing to run by hand.
10
+ --
11
+ -- Per link, `connector = 'outlook-app-only' AND trigger_id = 'message-received'`,
12
+ -- matched exactly (never `LIKE`: `outlook-app-only-2`, `outlook-catalog` and the
13
+ -- delegated `outlook` are other rows):
14
+ --
15
+ -- * the cursor. The built-in kept the ISO stamp of the newest mail it read; the
16
+ -- piece's TIMEBASED poll reads `{ "lastPoll": <epoch ms> }` and throws on
17
+ -- anything else, and filters `receivedDateTime gt` that instant, so the link
18
+ -- resumes where it stopped. Only a stamp with an explicit offset is read: one
19
+ -- without would be read in the session's time zone, and a guess is worse than
20
+ -- a link that refuses to poll. Anything else stays as it was (and is reported
21
+ -- below) rather than becoming epoch 0, which would replay the Inbox. A NULL
22
+ -- cursor stays NULL: arming the link runs the piece's own `onEnable`.
23
+ -- * the mapping. The built-in's event was its own flat map of Graph's message;
24
+ -- the piece's is the message as Graph answers it. A field the mapping reads
25
+ -- OFF THE EVENT is renamed: `messageId` to `id`, `snippet` to `bodyPreview`,
26
+ -- `date` to `receivedDateTime`, and `mailbox`, which the link stamped on, to
27
+ -- the mailbox itself as a string. Strings, later steps of a path, variables
28
+ -- and functions are never touched. The rewrite reads the JSONata token by
29
+ -- token; where it cannot be sure what a name reads (a predicate, a lambda,
30
+ -- the context `$` read whole or by name, an operator it does not model) it
31
+ -- leaves the mapping exactly as it was and reports it. That mapping fails at
32
+ -- the next event, where the operator sees it; a mangled one would keep
33
+ -- running with wrong data. A link with no mapping handed the workflow the
34
+ -- event itself, so it is given one that spells that event out.
35
+ -- * the mailbox goes from the link (`scope` `user-id`) to the connection, where
36
+ -- the piece reads it. The connector row is converted beside this, by the box
37
+ -- (`connect-tables.ts`) and by db-core 0038, before this runs.
38
+ -- * what described the old event goes: `mapping_graph` when the mapping
39
+ -- changed (it would compile back to the old names), and `sample_event`.
40
+ -- * `poll_revision` moves, so a poll result read before this is not saved.
41
+ --
42
+ -- The delivery journal needs nothing: both triggers key a delivery on Graph's
43
+ -- message id, so mail delivered before the move is not delivered again.
44
+
45
+ CREATE FUNCTION stackbone_platform.pla1932_last_poll(stored jsonb) RETURNS jsonb
46
+ LANGUAGE plpgsql AS $fn$
47
+ DECLARE
48
+ stamp text;
49
+ instant timestamptz;
50
+ BEGIN
51
+ IF stored IS NULL OR jsonb_typeof(stored) <> 'string' THEN
52
+ RETURN NULL;
53
+ END IF;
54
+ stamp := stored #>> '{}';
55
+ IF stamp !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})$' THEN
56
+ RETURN NULL;
57
+ END IF;
58
+ BEGIN
59
+ instant := stamp::timestamptz;
60
+ EXCEPTION WHEN others THEN
61
+ RETURN NULL;
62
+ END;
63
+ RETURN jsonb_build_object('lastPoll', floor(extract(epoch FROM instant) * 1000)::bigint);
64
+ END;
65
+ $fn$;
66
+
67
+ -- The JSONata over `message-received`'s event, rewritten over `newEmail`'s, or
68
+ -- NULL when it cannot be done with certainty. `context_is_event` tracks whether a
69
+ -- bare name at this point reads the event: true at the top, in a call's arguments,
70
+ -- in a group, an array or an object; false inside a predicate, a lambda body, a
71
+ -- block or object applied to a path, and a sort.
72
+ CREATE FUNCTION stackbone_platform.pla1932_new_email_jsonata(expr text, mailbox text)
73
+ RETURNS text
74
+ LANGUAGE plpgsql AS $fn$
75
+ DECLARE
76
+ -- Anything that ends a name: JSONata's operators, quotes and whitespace.
77
+ ends_name constant text := '[][{}().,;:?+*/%|=<>^&!~@#"''`$[:space:]-]';
78
+ n int := length(expr);
79
+ i int := 1;
80
+ j int;
81
+ c text;
82
+ nx text;
83
+ out text := '';
84
+ tok text;
85
+ word text;
86
+ quoted boolean;
87
+ is_field boolean;
88
+ -- The open brackets, innermost last, and what each one means.
89
+ opener text[] := '{}';
90
+ reads_event boolean[] := '{}';
91
+ is_object boolean[] := '{}';
92
+ reading_key boolean[] := '{}';
93
+ questions int[] := '{}';
94
+ is_params boolean[] := '{}';
95
+ depth int := 0;
96
+ top_questions int := 0;
97
+ context_is_event boolean := true;
98
+ closed_params boolean;
99
+ -- What came before: start, value, var, ctx (a bare `$`/`$$`), dot, op, open,
100
+ -- comma, colon, fn (`function`), params (a lambda's closed parameter list).
101
+ prev text := 'start';
102
+ before_dot text := 'other';
103
+ BEGIN
104
+ IF expr IS NULL THEN
105
+ RETURN NULL;
106
+ END IF;
107
+ WHILE i <= n LOOP
108
+ c := substr(expr, i, 1);
109
+ nx := substr(expr, i + 1, 1);
110
+
111
+ IF c ~ '[[:space:]]' THEN
112
+ out := out || c;
113
+ i := i + 1;
114
+ CONTINUE;
115
+ END IF;
116
+
117
+ IF c = '/' AND nx = '*' THEN
118
+ j := strpos(substr(expr, i + 2), '*/');
119
+ IF j = 0 THEN
120
+ RETURN NULL;
121
+ END IF;
122
+ out := out || substr(expr, i, j + 3);
123
+ i := i + j + 3;
124
+ CONTINUE;
125
+ END IF;
126
+
127
+ -- The context itself is understood only as `$.<field>`.
128
+ IF prev = 'ctx' AND c <> '.' THEN
129
+ RETURN NULL;
130
+ END IF;
131
+
132
+ IF c = '"' OR c = '''' THEN
133
+ j := i + 1;
134
+ LOOP
135
+ IF j > n THEN
136
+ RETURN NULL;
137
+ END IF;
138
+ IF substr(expr, j, 1) = '\' THEN
139
+ j := j + 2;
140
+ CONTINUE;
141
+ END IF;
142
+ EXIT WHEN substr(expr, j, 1) = c;
143
+ j := j + 1;
144
+ END LOOP;
145
+ out := out || substr(expr, i, j - i + 1);
146
+ i := j + 1;
147
+ prev := 'value';
148
+ CONTINUE;
149
+ END IF;
150
+
151
+ -- A regex literal stands where an operand does; elsewhere `/` divides.
152
+ IF c = '/' AND prev NOT IN ('value', 'var') THEN
153
+ j := i + 1;
154
+ LOOP
155
+ IF j > n THEN
156
+ RETURN NULL;
157
+ END IF;
158
+ IF substr(expr, j, 1) = '\' THEN
159
+ j := j + 2;
160
+ CONTINUE;
161
+ END IF;
162
+ EXIT WHEN substr(expr, j, 1) = '/';
163
+ j := j + 1;
164
+ END LOOP;
165
+ j := j + 1;
166
+ WHILE j <= n AND substr(expr, j, 1) ~ '[a-z]' LOOP
167
+ j := j + 1;
168
+ END LOOP;
169
+ out := out || substr(expr, i, j - i);
170
+ i := j;
171
+ prev := 'value';
172
+ CONTINUE;
173
+ END IF;
174
+
175
+ IF c = '$' THEN
176
+ j := i + 1;
177
+ IF nx = '$' THEN
178
+ j := j + 1;
179
+ ELSE
180
+ WHILE j <= n AND substr(expr, j, 1) !~ ends_name LOOP
181
+ j := j + 1;
182
+ END LOOP;
183
+ END IF;
184
+ tok := substr(expr, i, j - i);
185
+ out := out || tok;
186
+ i := j;
187
+ prev := CASE WHEN tok IN ('$', '$$') AND context_is_event THEN 'ctx' ELSE 'var' END;
188
+ CONTINUE;
189
+ END IF;
190
+
191
+ IF c ~ '[0-9]' THEN
192
+ j := i;
193
+ WHILE j <= n AND substr(expr, j, 1) ~ '[0-9]' LOOP
194
+ j := j + 1;
195
+ END LOOP;
196
+ IF substr(expr, j, 1) = '.' AND substr(expr, j + 1, 1) ~ '[0-9]' THEN
197
+ j := j + 1;
198
+ WHILE j <= n AND substr(expr, j, 1) ~ '[0-9]' LOOP
199
+ j := j + 1;
200
+ END LOOP;
201
+ END IF;
202
+ IF substr(expr, j, 2) ~ '^[eE][0-9]' OR substr(expr, j, 3) ~ '^[eE][+-][0-9]' THEN
203
+ j := j + 2;
204
+ WHILE j <= n AND substr(expr, j, 1) ~ '[0-9]' LOOP
205
+ j := j + 1;
206
+ END LOOP;
207
+ END IF;
208
+ out := out || substr(expr, i, j - i);
209
+ i := j;
210
+ prev := 'value';
211
+ CONTINUE;
212
+ END IF;
213
+
214
+ IF c = '`' OR c !~ ends_name THEN
215
+ IF c = '`' THEN
216
+ j := strpos(substr(expr, i + 1), '`');
217
+ IF j = 0 THEN
218
+ RETURN NULL;
219
+ END IF;
220
+ word := substr(expr, i + 1, j - 1);
221
+ tok := substr(expr, i, j + 1);
222
+ quoted := true;
223
+ i := i + j + 1;
224
+ ELSE
225
+ j := i;
226
+ WHILE j <= n AND substr(expr, j, 1) !~ ends_name LOOP
227
+ j := j + 1;
228
+ END LOOP;
229
+ word := substr(expr, i, j - i);
230
+ tok := word;
231
+ quoted := false;
232
+ i := j;
233
+ END IF;
234
+
235
+ IF NOT quoted AND word IN ('and', 'or', 'in') THEN
236
+ out := out || tok;
237
+ prev := 'op';
238
+ CONTINUE;
239
+ END IF;
240
+ IF NOT quoted AND word IN ('true', 'false', 'null') THEN
241
+ out := out || tok;
242
+ prev := 'value';
243
+ CONTINUE;
244
+ END IF;
245
+ IF NOT quoted AND word IN ('function', 'λ') THEN
246
+ out := out || tok;
247
+ prev := 'fn';
248
+ CONTINUE;
249
+ END IF;
250
+
251
+ is_field := word IN ('messageId', 'snippet', 'date', 'mailbox');
252
+ IF prev = 'dot' THEN
253
+ -- A later step of a path: another object's field, unless the step before
254
+ -- it is the context or a variable, whose value is unknown here.
255
+ IF is_field AND before_dot IN ('ctx', 'var') THEN
256
+ RETURN NULL;
257
+ END IF;
258
+ out := out || tok;
259
+ prev := 'value';
260
+ CONTINUE;
261
+ END IF;
262
+
263
+ IF NOT is_field THEN
264
+ out := out || tok;
265
+ ELSIF NOT context_is_event THEN
266
+ RETURN NULL;
267
+ ELSIF depth > 0 AND is_object[depth] AND reading_key[depth] THEN
268
+ -- An unquoted key is an expression: renaming it would rename the key.
269
+ RETURN NULL;
270
+ ELSIF word = 'mailbox' THEN
271
+ IF mailbox IS NULL THEN
272
+ RETURN NULL;
273
+ END IF;
274
+ -- The mailbox becomes a string, which has no field, no index and no call.
275
+ j := i;
276
+ WHILE j <= n AND substr(expr, j, 1) ~ '[[:space:]]' LOOP
277
+ j := j + 1;
278
+ END LOOP;
279
+ IF substr(expr, j, 1) IN ('.', '[', '(', '{', '/', '`') THEN
280
+ RETURN NULL;
281
+ END IF;
282
+ out := out || to_json(mailbox)::text;
283
+ ELSE
284
+ tok := CASE word
285
+ WHEN 'messageId' THEN 'id'
286
+ WHEN 'snippet' THEN 'bodyPreview'
287
+ ELSE 'receivedDateTime'
288
+ END;
289
+ out := out || CASE WHEN quoted THEN '`' || tok || '`' ELSE tok END;
290
+ END IF;
291
+ prev := 'value';
292
+ CONTINUE;
293
+ END IF;
294
+
295
+ IF c = '.' THEN
296
+ IF nx = '.' THEN
297
+ out := out || '..';
298
+ i := i + 2;
299
+ prev := 'op';
300
+ CONTINUE;
301
+ END IF;
302
+ before_dot := CASE WHEN prev IN ('ctx', 'var') THEN prev ELSE 'other' END;
303
+ out := out || c;
304
+ i := i + 1;
305
+ prev := 'dot';
306
+ CONTINUE;
307
+ END IF;
308
+
309
+ IF c IN ('(', '[', '{') OR (c = '^' AND nx = '(') THEN
310
+ depth := depth + 1;
311
+ opener[depth] := CASE WHEN c = '^' THEN '(' ELSE c END;
312
+ is_object[depth] := false;
313
+ reading_key[depth] := false;
314
+ questions[depth] := 0;
315
+ is_params[depth] := false;
316
+ IF c = '^' THEN
317
+ -- A sort's terms read each item.
318
+ reads_event[depth] := false;
319
+ out := out || '^(';
320
+ i := i + 2;
321
+ ELSE
322
+ IF c = '(' THEN
323
+ is_params[depth] := prev = 'fn';
324
+ -- A call's arguments and a group read the same context; a lambda's
325
+ -- parameters and a block applied to a path do not.
326
+ reads_event[depth] := context_is_event AND prev NOT IN ('fn', 'dot');
327
+ ELSIF c = '[' THEN
328
+ -- After a value it filters that value; at an operand it builds an array.
329
+ reads_event[depth] := context_is_event AND prev NOT IN ('value', 'var', 'dot');
330
+ ELSE
331
+ is_object[depth] := prev <> 'params';
332
+ reading_key[depth] := prev <> 'params';
333
+ -- An object built at an operand reads the context; after a path it
334
+ -- groups that path's items, and after a lambda's parameters it is the
335
+ -- lambda's body.
336
+ reads_event[depth] := context_is_event AND prev NOT IN ('value', 'var', 'dot', 'params');
337
+ END IF;
338
+ out := out || c;
339
+ i := i + 1;
340
+ END IF;
341
+ context_is_event := reads_event[depth];
342
+ prev := 'open';
343
+ CONTINUE;
344
+ END IF;
345
+
346
+ IF c IN (')', ']', '}') THEN
347
+ IF depth = 0 OR opener[depth] <> (CASE c WHEN ')' THEN '(' WHEN ']' THEN '[' ELSE '{' END) THEN
348
+ RETURN NULL;
349
+ END IF;
350
+ closed_params := is_params[depth];
351
+ depth := depth - 1;
352
+ context_is_event := CASE WHEN depth = 0 THEN true ELSE reads_event[depth] END;
353
+ out := out || c;
354
+ i := i + 1;
355
+ prev := CASE WHEN closed_params THEN 'params' ELSE 'value' END;
356
+ CONTINUE;
357
+ END IF;
358
+
359
+ IF c = ',' THEN
360
+ IF depth > 0 AND is_object[depth] THEN
361
+ reading_key[depth] := true;
362
+ questions[depth] := 0;
363
+ END IF;
364
+ out := out || c;
365
+ i := i + 1;
366
+ prev := 'comma';
367
+ CONTINUE;
368
+ END IF;
369
+
370
+ IF c = ':' THEN
371
+ IF nx = '=' THEN
372
+ out := out || ':=';
373
+ i := i + 2;
374
+ prev := 'op';
375
+ CONTINUE;
376
+ END IF;
377
+ IF depth > 0 AND questions[depth] > 0 THEN
378
+ questions[depth] := questions[depth] - 1;
379
+ prev := 'op';
380
+ ELSIF depth = 0 AND top_questions > 0 THEN
381
+ top_questions := top_questions - 1;
382
+ prev := 'op';
383
+ ELSIF depth > 0 AND is_object[depth] AND reading_key[depth] THEN
384
+ reading_key[depth] := false;
385
+ prev := 'colon';
386
+ ELSE
387
+ RETURN NULL;
388
+ END IF;
389
+ out := out || c;
390
+ i := i + 1;
391
+ CONTINUE;
392
+ END IF;
393
+
394
+ IF c = '?' THEN
395
+ IF nx IN (':', '?') THEN
396
+ out := out || c || nx;
397
+ i := i + 2;
398
+ ELSE
399
+ IF depth > 0 THEN
400
+ questions[depth] := questions[depth] + 1;
401
+ ELSE
402
+ top_questions := top_questions + 1;
403
+ END IF;
404
+ out := out || c;
405
+ i := i + 1;
406
+ END IF;
407
+ prev := 'op';
408
+ CONTINUE;
409
+ END IF;
410
+
411
+ IF c = '*' THEN
412
+ IF nx = '*' THEN
413
+ RETURN NULL;
414
+ END IF;
415
+ IF prev IN ('value', 'var') THEN
416
+ prev := 'op';
417
+ ELSIF prev = 'dot' OR NOT context_is_event THEN
418
+ prev := 'value';
419
+ ELSE
420
+ -- Every field of the event at once.
421
+ RETURN NULL;
422
+ END IF;
423
+ out := out || c;
424
+ i := i + 1;
425
+ CONTINUE;
426
+ END IF;
427
+
428
+ IF (c = '~' AND nx = '>') OR (c = '!' AND nx = '=') OR (c IN ('<', '>') AND nx = '=') THEN
429
+ out := out || c || nx;
430
+ i := i + 2;
431
+ prev := 'op';
432
+ CONTINUE;
433
+ END IF;
434
+
435
+ IF c IN (';', '=', '<', '>', '+', '-', '&', '/') THEN
436
+ out := out || c;
437
+ i := i + 1;
438
+ prev := 'op';
439
+ CONTINUE;
440
+ END IF;
441
+
442
+ -- `|` (transform), `@` and `#` (bindings), `%` (parent or modulo), a lone `~`,
443
+ -- `!` or `^`, and anything else: not modelled.
444
+ RETURN NULL;
445
+ END LOOP;
446
+
447
+ IF depth <> 0 OR prev = 'ctx' THEN
448
+ RETURN NULL;
449
+ END IF;
450
+ RETURN out;
451
+ END;
452
+ $fn$;
453
+
454
+ DO $$
455
+ DECLARE
456
+ link record;
457
+ v_mailbox text;
458
+ v_cursor jsonb;
459
+ v_mapping text;
460
+ v_correlation text;
461
+ BEGIN
462
+ FOR link IN
463
+ SELECT id, scope, poll_cursor, mapping, correlation_key
464
+ FROM stackbone_platform.trigger_subscriptions
465
+ WHERE connector = 'outlook-app-only'
466
+ AND trigger_id = 'message-received'
467
+ ORDER BY created_at, id
468
+ FOR UPDATE
469
+ LOOP
470
+ v_mailbox := nullif(btrim(link.scope ->> 'user-id'), '');
471
+
472
+ v_cursor := link.poll_cursor;
473
+ IF link.poll_cursor IS NOT NULL THEN
474
+ v_cursor := stackbone_platform.pla1932_last_poll(link.poll_cursor);
475
+ IF v_cursor IS NULL THEN
476
+ v_cursor := link.poll_cursor;
477
+ RAISE WARNING 'PLA-1932: trigger link % keeps its cursor %: it is no timestamp newEmail can resume from. Arm the link again, starting from now.',
478
+ link.id, link.poll_cursor;
479
+ END IF;
480
+ END IF;
481
+
482
+ IF link.mapping IS NULL THEN
483
+ v_mapping := '{ "messageId": id, "conversationId": conversationId, "from": from, "subject": subject, "snippet": bodyPreview, "date": receivedDateTime, "isRead": isRead'
484
+ || CASE WHEN v_mailbox IS NULL THEN '' ELSE ', "mailbox": ' || to_json(v_mailbox)::text END
485
+ || ' }';
486
+ ELSE
487
+ v_mapping := stackbone_platform.pla1932_new_email_jsonata(link.mapping, v_mailbox);
488
+ IF v_mapping IS NULL THEN
489
+ v_mapping := link.mapping;
490
+ RAISE WARNING 'PLA-1932: trigger link % keeps its mapping as it was: rewrite it over the newEmail event (id, bodyPreview, receivedDateTime, and the mailbox as a string).',
491
+ link.id;
492
+ END IF;
493
+ END IF;
494
+
495
+ v_correlation := link.correlation_key;
496
+ IF link.correlation_key IS NOT NULL THEN
497
+ v_correlation := coalesce(
498
+ stackbone_platform.pla1932_new_email_jsonata(link.correlation_key, v_mailbox),
499
+ link.correlation_key
500
+ );
501
+ END IF;
502
+
503
+ UPDATE stackbone_platform.trigger_subscriptions
504
+ SET connector = 'microsoft-outlook',
505
+ trigger_id = 'newEmail',
506
+ poll_cursor = v_cursor,
507
+ mapping = v_mapping,
508
+ mapping_graph = CASE
509
+ WHEN v_mapping IS DISTINCT FROM link.mapping THEN NULL
510
+ ELSE mapping_graph
511
+ END,
512
+ correlation_key = v_correlation,
513
+ scope = NULL,
514
+ sample_event = NULL,
515
+ poll_revision = poll_revision + 1,
516
+ updated_at = now()
517
+ WHERE id = link.id;
518
+ END LOOP;
519
+ END $$;
520
+
521
+ DROP FUNCTION stackbone_platform.pla1932_last_poll(jsonb);
522
+ DROP FUNCTION stackbone_platform.pla1932_new_email_jsonata(text, text);
@@ -0,0 +1,13 @@
1
+ -- 0093_trigger_subscriptions_webhook_token.sql — a webhook link's ingress token (PLA-1856)
2
+ -- A piece's WEBHOOK trigger is delivered by the provider POSTing to the box at
3
+ -- `/api/hooks/<link id>/<token>`. The route is public, so the token is the credential:
4
+ -- 128 random bits, minted when the link arms, cleared when it disarms and minted
5
+ -- again on the next arm, so a URL the provider held before stops answering.
6
+ --
7
+ -- Plaintext, compared in constant time: `onDisable` is handed the same URL `onEnable`
8
+ -- registered, which a digest could not rebuild. Never returned by a list or a read.
9
+ -- Nullable: NULL is "no ingress", which every polling link and every link that is off
10
+ -- reads as.
11
+
12
+ ALTER TABLE stackbone_platform.trigger_subscriptions
13
+ ADD COLUMN IF NOT EXISTS webhook_token text;
@@ -0,0 +1,61 @@
1
+ -- 0094_rag_document_versions.sql — RAG document versions (STA3-8)
2
+ -- Every version of a document is its own row, with its own `source` (its storage
3
+ -- key) and its own chunks, so rollback flips a flag and re-embeds nothing. The
4
+ -- versions of one document share a `document_key`; the agent reads only the
5
+ -- `is_active` one.
6
+ --
7
+ -- A document that existed before this is its own version 1, published, keyed by
8
+ -- its `source`.
9
+
10
+ ALTER TABLE stackbone_platform.rag_documents
11
+ ADD COLUMN IF NOT EXISTS document_key text,
12
+ ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1,
13
+ ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
14
+
15
+ -- Two overlapping ingests of one source could leave two rows before PLA-2327's
16
+ -- lock. The newest keeps the source as its key; the older ones become documents
17
+ -- of their own, still published, so nothing the agent reads changes and the
18
+ -- unique index below can build. Deleting them would lose data.
19
+ UPDATE stackbone_platform.rag_documents d
20
+ SET document_key = CASE WHEN ranked.rn = 1 THEN d.source ELSE d.source || '#' || d.id END
21
+ FROM (
22
+ SELECT id, row_number() OVER (
23
+ PARTITION BY collection_id, source ORDER BY created_at DESC, id DESC
24
+ ) AS rn
25
+ FROM stackbone_platform.rag_documents
26
+ WHERE document_key IS NULL
27
+ ) ranked
28
+ WHERE d.id = ranked.id;
29
+
30
+ -- Postgres cannot default one column to another. A workspace on an SDK older
31
+ -- than this migration inserts without the key, so the trigger fills it in
32
+ -- instead of failing every ingest on NOT NULL.
33
+ CREATE OR REPLACE FUNCTION stackbone_platform.rag_documents_default_key()
34
+ RETURNS trigger
35
+ LANGUAGE plpgsql
36
+ AS $$
37
+ BEGIN
38
+ NEW.document_key := COALESCE(NEW.document_key, NEW.source);
39
+ RETURN NEW;
40
+ END;
41
+ $$;
42
+
43
+ DROP TRIGGER IF EXISTS rag_documents_default_key ON stackbone_platform.rag_documents;
44
+ CREATE TRIGGER rag_documents_default_key
45
+ BEFORE INSERT ON stackbone_platform.rag_documents
46
+ FOR EACH ROW EXECUTE FUNCTION stackbone_platform.rag_documents_default_key();
47
+
48
+ ALTER TABLE stackbone_platform.rag_documents
49
+ ALTER COLUMN document_key SET NOT NULL;
50
+
51
+ -- At most one published version per document. Non-deferrable, so publishing is
52
+ -- two statements (unpublish the siblings, then publish the target): one swapping
53
+ -- UPDATE can reach the target before the sibling and fail on the check.
54
+ CREATE UNIQUE INDEX IF NOT EXISTS rag_documents_active_key_uq
55
+ ON stackbone_platform.rag_documents (collection_id, document_key)
56
+ WHERE is_active;
57
+
58
+ -- Covers unpublished versions too: a writer that misses the key lock and hands
59
+ -- out a version number twice fails here instead of leaving two v2s behind.
60
+ CREATE UNIQUE INDEX IF NOT EXISTS rag_documents_key_version_uq
61
+ ON stackbone_platform.rag_documents (collection_id, document_key, version);
@@ -0,0 +1,22 @@
1
+ -- 0095_rag_document_version_counters.sql — a version number is never reused (STA3-8 D11)
2
+ -- The last version number each document handed out. Numbering from the rows
3
+ -- left (`max(version) + 1`) gave a deleted draft's number to the next upload,
4
+ -- and the activity log then showed "deleted v3" next to a live, different v3.
5
+ -- A new version takes this counter or the rows' highest version, whichever is
6
+ -- greater, plus one, under the document's key lock. Deleting the whole document
7
+ -- drops its row; deleting one version leaves it alone.
8
+
9
+ CREATE TABLE IF NOT EXISTS stackbone_platform.rag_document_version_counters (
10
+ collection_id uuid NOT NULL REFERENCES stackbone_platform.rag_collections (id) ON DELETE CASCADE,
11
+ document_key text NOT NULL,
12
+ last_version integer NOT NULL,
13
+ PRIMARY KEY (collection_id, document_key)
14
+ );
15
+
16
+ -- A document from before this starts from the highest version it still holds:
17
+ -- a number deleted before 0095 cannot be told apart from one never handed out.
18
+ INSERT INTO stackbone_platform.rag_document_version_counters (collection_id, document_key, last_version)
19
+ SELECT collection_id, document_key, max(version)
20
+ FROM stackbone_platform.rag_documents
21
+ GROUP BY collection_id, document_key
22
+ ON CONFLICT (collection_id, document_key) DO NOTHING;