@kairos-es/feed-postgres 0.0.0-stage → 0.0.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 (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +84 -2
  3. package/dist/cjs/config.js +46 -0
  4. package/dist/cjs/config.js.map +1 -0
  5. package/dist/cjs/ensureFeedSchema.js +39 -0
  6. package/dist/cjs/ensureFeedSchema.js.map +1 -0
  7. package/dist/cjs/feed.js +176 -0
  8. package/dist/cjs/feed.js.map +1 -0
  9. package/dist/cjs/index.js +84 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/context.js +45 -0
  12. package/dist/cjs/internal/context.js.map +1 -0
  13. package/dist/cjs/internal/ddl.js +149 -0
  14. package/dist/cjs/internal/ddl.js.map +1 -0
  15. package/dist/cjs/internal/statements.js +270 -0
  16. package/dist/cjs/internal/statements.js.map +1 -0
  17. package/dist/cjs/lease.js +342 -0
  18. package/dist/cjs/lease.js.map +1 -0
  19. package/dist/cjs/syncStore.js +179 -0
  20. package/dist/cjs/syncStore.js.map +1 -0
  21. package/dist/cjs/withFeed.js +51 -0
  22. package/dist/cjs/withFeed.js.map +1 -0
  23. package/dist/dts-cjs/config.d.ts +37 -0
  24. package/dist/dts-cjs/config.d.ts.map +1 -0
  25. package/dist/dts-cjs/ensureFeedSchema.d.ts +24 -0
  26. package/dist/dts-cjs/ensureFeedSchema.d.ts.map +1 -0
  27. package/dist/dts-cjs/feed.d.ts +81 -0
  28. package/dist/dts-cjs/feed.d.ts.map +1 -0
  29. package/dist/dts-cjs/index.d.ts +50 -0
  30. package/dist/dts-cjs/index.d.ts.map +1 -0
  31. package/dist/dts-cjs/internal/context.d.ts +28 -0
  32. package/dist/dts-cjs/internal/context.d.ts.map +1 -0
  33. package/dist/dts-cjs/internal/ddl.d.ts +99 -0
  34. package/dist/dts-cjs/internal/ddl.d.ts.map +1 -0
  35. package/dist/dts-cjs/internal/statements.d.ts +127 -0
  36. package/dist/dts-cjs/internal/statements.d.ts.map +1 -0
  37. package/dist/dts-cjs/lease.d.ts +138 -0
  38. package/dist/dts-cjs/lease.d.ts.map +1 -0
  39. package/dist/dts-cjs/package.json +3 -0
  40. package/dist/dts-cjs/syncStore.d.ts +57 -0
  41. package/dist/dts-cjs/syncStore.d.ts.map +1 -0
  42. package/dist/dts-cjs/withFeed.d.ts +69 -0
  43. package/dist/dts-cjs/withFeed.d.ts.map +1 -0
  44. package/dist/dts-esm/config.d.ts +37 -0
  45. package/dist/dts-esm/config.d.ts.map +1 -0
  46. package/dist/dts-esm/ensureFeedSchema.d.ts +24 -0
  47. package/dist/dts-esm/ensureFeedSchema.d.ts.map +1 -0
  48. package/dist/dts-esm/feed.d.ts +81 -0
  49. package/dist/dts-esm/feed.d.ts.map +1 -0
  50. package/dist/dts-esm/index.d.ts +50 -0
  51. package/dist/dts-esm/index.d.ts.map +1 -0
  52. package/dist/dts-esm/internal/context.d.ts +28 -0
  53. package/dist/dts-esm/internal/context.d.ts.map +1 -0
  54. package/dist/dts-esm/internal/ddl.d.ts +99 -0
  55. package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
  56. package/dist/dts-esm/internal/statements.d.ts +127 -0
  57. package/dist/dts-esm/internal/statements.d.ts.map +1 -0
  58. package/dist/dts-esm/lease.d.ts +138 -0
  59. package/dist/dts-esm/lease.d.ts.map +1 -0
  60. package/dist/dts-esm/package.json +3 -0
  61. package/dist/dts-esm/syncStore.d.ts +57 -0
  62. package/dist/dts-esm/syncStore.d.ts.map +1 -0
  63. package/dist/dts-esm/withFeed.d.ts +69 -0
  64. package/dist/dts-esm/withFeed.d.ts.map +1 -0
  65. package/dist/esm/config.js +39 -0
  66. package/dist/esm/config.js.map +1 -0
  67. package/dist/esm/ensureFeedSchema.js +31 -0
  68. package/dist/esm/ensureFeedSchema.js.map +1 -0
  69. package/dist/esm/feed.js +166 -0
  70. package/dist/esm/feed.js.map +1 -0
  71. package/dist/esm/index.js +50 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/internal/context.js +34 -0
  74. package/dist/esm/internal/context.js.map +1 -0
  75. package/dist/esm/internal/ddl.js +136 -0
  76. package/dist/esm/internal/ddl.js.map +1 -0
  77. package/dist/esm/internal/statements.js +262 -0
  78. package/dist/esm/internal/statements.js.map +1 -0
  79. package/dist/esm/lease.js +330 -0
  80. package/dist/esm/lease.js.map +1 -0
  81. package/dist/esm/package.json +4 -0
  82. package/dist/esm/syncStore.js +171 -0
  83. package/dist/esm/syncStore.js.map +1 -0
  84. package/dist/esm/withFeed.js +44 -0
  85. package/dist/esm/withFeed.js.map +1 -0
  86. package/package.json +35 -3
  87. package/src/config.ts +46 -0
  88. package/src/ensureFeedSchema.ts +32 -0
  89. package/src/feed.ts +284 -0
  90. package/src/index.ts +67 -0
  91. package/src/internal/context.ts +44 -0
  92. package/src/internal/ddl.ts +233 -0
  93. package/src/internal/statements.ts +387 -0
  94. package/src/lease.ts +503 -0
  95. package/src/syncStore.ts +340 -0
  96. package/src/withFeed.ts +148 -0
@@ -0,0 +1,136 @@
1
+ /** The default schema, identical to the event store's and the read side's. */
2
+ export const DEFAULT_SCHEMA = 'public';
3
+ /** The default table prefix, identical to the event store's and the read side's. */
4
+ export const DEFAULT_TABLE_PREFIX = 'dcb_events';
5
+ const MAX_IDENTIFIER_BYTES = 63;
6
+ const utf8ByteLength = value => new TextEncoder().encode(value).length;
7
+ const suffixes = {
8
+ rows: '_feed_rows',
9
+ rowsSeqIndex: '_feed_rows_seq_idx',
10
+ sequence: '_feed_seq',
11
+ generations: '_feed_generations',
12
+ cursors: '_feed_cursors',
13
+ parked: '_feed_parked'
14
+ };
15
+ /**
16
+ * The single-atom identifiers Postgres materialises for a config. The index is
17
+ * measured, unlike a primary key's implicit one, because it is named explicitly
18
+ * and `CREATE INDEX IF NOT EXISTS` would skip a truncated name that collides with
19
+ * another prefix's index instead of reporting it.
20
+ */
21
+ const derivedAtoms = config => {
22
+ const schema = config.schema ?? DEFAULT_SCHEMA;
23
+ const prefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
24
+ return [['schema', schema], ['feed rows table', `${prefix}${suffixes.rows}`], ['feed rows index', `${prefix}${suffixes.rowsSeqIndex}`], ['feed sequence', `${prefix}${suffixes.sequence}`], ['feed generations table', `${prefix}${suffixes.generations}`], ['feed cursors table', `${prefix}${suffixes.cursors}`], ['feed parked table', `${prefix}${suffixes.parked}`]];
25
+ };
26
+ /**
27
+ * The first derived identifier over Postgres's 63-byte limit, as a message, or
28
+ * `undefined` when every name fits. Postgres truncates an over-long identifier
29
+ * with only a notice, so two prefixes sharing a 63-byte stem would silently share
30
+ * one feed table; refusing at the boundary beats that.
31
+ */
32
+ export const oversizedIdentifier = config => {
33
+ for (const [label, atom] of derivedAtoms(config)) {
34
+ const bytes = utf8ByteLength(atom);
35
+ if (bytes > MAX_IDENTIFIER_BYTES) {
36
+ return `the ${label} identifier "${atom}" is ${bytes} bytes, over Postgres's ${MAX_IDENTIFIER_BYTES}-byte limit; it would be silently truncated, letting two prefixes share one feed object`;
37
+ }
38
+ }
39
+ return undefined;
40
+ };
41
+ /** Derive every name from the pair; throws on an over-long identifier. */
42
+ export const resolveFeedNames = config => {
43
+ const problem = oversizedIdentifier(config);
44
+ if (problem !== undefined) {
45
+ throw new Error(`resolveFeedNames: ${problem}`);
46
+ }
47
+ const schema = config.schema ?? DEFAULT_SCHEMA;
48
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
49
+ const qualified = suffix => `${schema}.${tablePrefix}${suffix}`;
50
+ return {
51
+ schema,
52
+ tablePrefix,
53
+ rowsTable: qualified(suffixes.rows),
54
+ rowsSeqIndex: `${tablePrefix}${suffixes.rowsSeqIndex}`,
55
+ sequence: qualified(suffixes.sequence),
56
+ generationsTable: qualified(suffixes.generations),
57
+ cursorsTable: qualified(suffixes.cursors),
58
+ parkedTable: qualified(suffixes.parked)
59
+ };
60
+ };
61
+ /** Quote a possibly qualified identifier, atom by atom, for literal SQL text. */
62
+ export const quoteQualified = name => name.split('.').map(atom => quoteAtom(atom)).join('.');
63
+ /** Quote one identifier atom, doubling any embedded `"`. */
64
+ export const quoteAtom = name => `"${name.replace(/"/g, '""')}"`;
65
+ /**
66
+ * The quoted sequence name as a SQL string LITERAL, for `nextval('…')`. The
67
+ * literal is parsed as a `regclass`, which honours the double quotes inside it, so
68
+ * an awkward prefix resolves to exactly the object the DDL created; any `'` in it
69
+ * is doubled so the literal cannot be closed early.
70
+ */
71
+ export const sequenceLiteral = names => `'${quoteQualified(names.sequence).replace(/'/g, "''")}'`;
72
+ /**
73
+ * The ordered, idempotent DDL. Every statement is `IF NOT EXISTS`, so a second
74
+ * run against existing objects is a no-op that keeps every row.
75
+ *
76
+ * Shapes that are load-bearing:
77
+ *
78
+ * - `_feed_rows` is keyed `(feed, key)`: one row per record, which is what
79
+ * "coalesced, latest wins" means in storage, and the arbiter the writer's
80
+ * `ON CONFLICT` names. `doc` is NULL for a tombstone; a document that encodes to
81
+ * JSON `null` is the jsonb value `null`, which is not SQL NULL, so the two
82
+ * cannot be confused.
83
+ * - The `(feed, seq)` index is what `changesAfter` pages by.
84
+ * - ONE sequence serves every feed in the prefix. A feed's `seq` values therefore
85
+ * have gaps, which the cursor never minds: it compares, never counts.
86
+ * - `_feed_cursors` holds the cursor AND the lease, so the cursor's
87
+ * compare-and-set can fence on the lease holder in the same row it moves. The
88
+ * cursor is `(generation, seq)`, both NULL before a sink's first sync and never
89
+ * one without the other, which the `CHECK` holds. `last_advance_at` is when
90
+ * the cursor last moved, NULL until it first does; taking a lease leaves it
91
+ * alone, so a sink registered and never run shows as one.
92
+ * - `_feed_generations` holds the checkpoint key a feed is bound to, so binding
93
+ * one feed to two materialisations is refused across processes, not only within
94
+ * one.
95
+ */
96
+ export const feedDdlStatements = names => {
97
+ const rows = quoteQualified(names.rowsTable);
98
+ const generations = quoteQualified(names.generationsTable);
99
+ const cursors = quoteQualified(names.cursorsTable);
100
+ const parked = quoteQualified(names.parkedTable);
101
+ return [`CREATE SCHEMA IF NOT EXISTS ${quoteAtom(names.schema)};`, `CREATE SEQUENCE IF NOT EXISTS ${quoteQualified(names.sequence)};`, `CREATE TABLE IF NOT EXISTS ${rows} (
102
+ "feed" text NOT NULL,
103
+ "key" text NOT NULL,
104
+ "doc" jsonb NULL,
105
+ "seq" bigint NOT NULL,
106
+ "event_position" bigint NOT NULL,
107
+ PRIMARY KEY ("feed", "key")
108
+ );`, `CREATE INDEX IF NOT EXISTS ${quoteAtom(names.rowsSeqIndex)}
109
+ ON ${rows} ("feed", "seq");`, `CREATE TABLE IF NOT EXISTS ${generations} (
110
+ "feed" text PRIMARY KEY,
111
+ "generation" bigint NOT NULL,
112
+ "checkpoint_projection" text NULL,
113
+ "checkpoint_partition" text NULL
114
+ );`, `CREATE TABLE IF NOT EXISTS ${cursors} (
115
+ "feed" text NOT NULL,
116
+ "sink" text NOT NULL,
117
+ "generation" bigint NULL,
118
+ "seq" bigint NULL,
119
+ "lease_holder" text NULL,
120
+ "lease_expires_at" timestamptz NULL,
121
+ "last_advance_at" timestamptz NULL,
122
+ PRIMARY KEY ("feed", "sink"),
123
+ CHECK (("generation" IS NULL) = ("seq" IS NULL))
124
+ );`, `CREATE TABLE IF NOT EXISTS ${parked} (
125
+ "feed" text NOT NULL,
126
+ "sink" text NOT NULL,
127
+ "key" text NOT NULL,
128
+ "seq" bigint NOT NULL,
129
+ "reason" text NOT NULL,
130
+ "parked_at" timestamptz NOT NULL,
131
+ PRIMARY KEY ("feed", "sink", "key")
132
+ );`];
133
+ };
134
+ /** The DDL as statements on `sql`, one `sql.unsafe` each, in order. */
135
+ export const runFeedDdl = (sql, names) => feedDdlStatements(names).map(statement => sql.unsafe(statement));
136
+ //# sourceMappingURL=ddl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl.js","names":["DEFAULT_SCHEMA","DEFAULT_TABLE_PREFIX","MAX_IDENTIFIER_BYTES","utf8ByteLength","value","TextEncoder","encode","length","suffixes","rows","rowsSeqIndex","sequence","generations","cursors","parked","derivedAtoms","config","schema","prefix","tablePrefix","oversizedIdentifier","label","atom","bytes","undefined","resolveFeedNames","problem","Error","qualified","suffix","rowsTable","generationsTable","cursorsTable","parkedTable","quoteQualified","name","split","map","quoteAtom","join","replace","sequenceLiteral","names","feedDdlStatements","runFeedDdl","sql","statement","unsafe"],"sources":["../../../src/internal/ddl.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Every runtime statement text the Postgres change feed issues, compiled once per
3
+ * name set, and the conventions their parameters follow.
4
+ *
5
+ * One module, so what the feed sends to the database is auditable in one file.
6
+ * The Effect wiring that issues them lives in `feed.ts`, `syncStore.ts`, `lease.ts`
7
+ * and `withFeed.ts`; none of those writes SQL of its own.
8
+ *
9
+ * ## Parameter conventions
10
+ *
11
+ * - Every 64-bit value (`seq`, `generation`, `event_position`) is bound as a
12
+ * decimal STRING and cast (`$n::bigint`), and read back as text and turned into
13
+ * a `bigint`, so no value ever passes through a JS `number` and no driver's
14
+ * integer handling can round one.
15
+ * - A batch of rows is bound as ONE `jsonb` parameter and unpacked server-side
16
+ * (`jsonb_to_recordset`, `jsonb_array_elements_text`), so a batch is one
17
+ * statement whatever its size and no array-binding support is asked of the
18
+ * driver.
19
+ * - A timestamp is bound as epoch MILLISECONDS from Effect's `Clock`, and read back
20
+ * the same way. Lease expiry is judged against the worker's Clock rather than the
21
+ * database's `now()`, so a `TestClock` governs it in tests as it governs every
22
+ * other time in this repository; the cost is that workers on several hosts must
23
+ * keep their clocks within a small fraction of the lease length of each other.
24
+ * - A document is read back as `doc::text` and parsed here, so a consumer's JSON
25
+ * parsing or name transforms cannot reshape it on the way.
26
+ * - A column read back as text is ALIASED to its own name, so every `ORDER BY`
27
+ * qualifies the column with its table alias: an unqualified name in `ORDER BY`
28
+ * resolves to the output column first, and `seq` would then sort as text, with
29
+ * `10` before `4`.
30
+ *
31
+ * ## The feed lock
32
+ *
33
+ * The generations row of a feed doubles as its WRITE LOCK. A feed's `seq` is
34
+ * ascending in commit order only while one transaction at a time takes `seq`
35
+ * values for that feed, and the subscribe machine silently loses a row whose `seq`
36
+ * commits below one already read (ADR-0009). The materialisation is one writer, but
37
+ * two other things take `seq` values for a feed outside its commit: `retryParked`
38
+ * re-stamps a row, and a reset empties the feed. So the writer takes the row
39
+ * `FOR SHARE` before it takes any `seq`, `retryParked` takes it `FOR UPDATE`, and
40
+ * the reset UPDATEs it; each waits for the others to commit, and `seq` order is
41
+ * commit order for all three.
42
+ */
43
+ import { quoteQualified, sequenceLiteral } from "./ddl.js";
44
+ /** Compile every statement for `names`. */
45
+ export const compileFeedStatements = names => {
46
+ const rows = quoteQualified(names.rowsTable);
47
+ const generations = quoteQualified(names.generationsTable);
48
+ const cursors = quoteQualified(names.cursorsTable);
49
+ const parked = quoteQualified(names.parkedTable);
50
+ const nextSeq = `nextval(${sequenceLiteral(names)})`;
51
+ const msToTimestamp = param => `to_timestamp(${param}::double precision / 1000)`;
52
+ const timestampToMs = column => `round(extract(epoch from ${column}) * 1000)::bigint::text`;
53
+ const advisoryKeyOf = param => `hashtextextended(${param}, 0)`;
54
+ const advisoryKey = advisoryKeyOf('$1');
55
+ // Whether THIS session holds the advisory lock named by `param`. A bigint
56
+ // advisory key is stored in `pg_locks` split into two 32-bit halves, `classid`
57
+ // the high and `objid` the low, with `objsubid` 1.
58
+ const sessionHoldsLock = param => `EXISTS (
59
+ SELECT 1 FROM pg_locks l
60
+ WHERE l.locktype = 'advisory'
61
+ AND l.pid = pg_backend_pid()
62
+ AND l.granted
63
+ AND l.objsubid = 1
64
+ AND l.classid = ((${advisoryKeyOf(param)} >> 32) & 4294967295)::oid
65
+ AND l.objid = (${advisoryKeyOf(param)} & 4294967295)::oid
66
+ )`;
67
+ return {
68
+ ensureGeneration: `INSERT INTO ${generations} ("feed", "generation")
69
+ VALUES ($1, 0)
70
+ ON CONFLICT ("feed") DO NOTHING`,
71
+ lockFeedShared: `SELECT 1 AS locked FROM ${generations}
72
+ WHERE "feed" = $1 FOR SHARE`,
73
+ // One `nextval` per INSERTED candidate, kept by the update arm through
74
+ // `excluded.seq`, so an unchanged document burns a value and writes nothing:
75
+ // the `WHERE … IS DISTINCT FROM` arm compares the normalised jsonb, so key
76
+ // order is no change.
77
+ upsertRows: `INSERT INTO ${rows} AS r ("feed", "key", "doc", "seq", "event_position")
78
+ SELECT $1, t."key", t."doc", ${nextSeq}, $2::bigint
79
+ FROM jsonb_to_recordset($3::jsonb) AS t("key" text, "doc" jsonb)
80
+ ON CONFLICT ("feed", "key") DO UPDATE
81
+ SET "doc" = excluded."doc",
82
+ "seq" = excluded."seq",
83
+ "event_position" = excluded."event_position"
84
+ WHERE r."doc" IS DISTINCT FROM excluded."doc"`,
85
+ // An UPDATE, never an insert: a remove of an absent key writes nothing, and
86
+ // neither does a remove of a key that is already a tombstone.
87
+ tombstoneRows: `UPDATE ${rows} AS r
88
+ SET "doc" = NULL, "seq" = ${nextSeq}, "event_position" = $2::bigint
89
+ FROM jsonb_array_elements_text($3::jsonb) AS t("key")
90
+ WHERE r."feed" = $1 AND r."key" = t."key" AND r."doc" IS NOT NULL`,
91
+ selectGeneration: `SELECT "generation"::text AS generation
92
+ FROM ${generations} WHERE "feed" = $1`,
93
+ changesAfter: `SELECT "key" AS key, "doc"::text AS doc, "seq"::text AS seq,
94
+ "event_position"::text AS event_position
95
+ FROM ${rows} AS r
96
+ WHERE r."feed" = $1 AND r."seq" > $2::bigint
97
+ ORDER BY r."seq"
98
+ LIMIT $3`,
99
+ selectCursor: `SELECT "generation"::text AS generation, "seq"::text AS seq
100
+ FROM ${cursors} WHERE "feed" = $1 AND "sink" = $2`,
101
+ // Guarded like a checkpoint AND fenced on the holder: zero rows means the
102
+ // stored cursor was not the expected one, or the lease is somebody else's.
103
+ casCursor: `UPDATE ${cursors}
104
+ SET "generation" = $4::bigint, "seq" = $5::bigint,
105
+ "last_advance_at" = ${msToTimestamp('$8')}
106
+ WHERE "feed" = $1 AND "sink" = $2 AND "lease_holder" = $3
107
+ AND "generation" IS NOT DISTINCT FROM $6::bigint
108
+ AND "seq" IS NOT DISTINCT FROM $7::bigint
109
+ RETURNING 1 AS landed`,
110
+ clearParked: `DELETE FROM ${parked} WHERE "feed" = $1 AND "sink" = $2`,
111
+ unparkKeys: `DELETE FROM ${parked}
112
+ WHERE "feed" = $1 AND "sink" = $2
113
+ AND "key" IN (SELECT jsonb_array_elements_text($3::jsonb))`,
114
+ parkKeys: `INSERT INTO ${parked} ("feed", "sink", "key", "seq", "reason", "parked_at")
115
+ SELECT $1, $2, t."key", t."seq"::bigint, t."reason", ${msToTimestamp('t."parked_at_ms"')}
116
+ FROM jsonb_to_recordset($3::jsonb)
117
+ AS t("key" text, "seq" text, "reason" text, "parked_at_ms" text)
118
+ ON CONFLICT ("feed", "sink", "key") DO UPDATE
119
+ SET "seq" = excluded."seq", "reason" = excluded."reason",
120
+ "parked_at" = excluded."parked_at"`,
121
+ selectParked: `SELECT "key" AS key, "seq"::text AS seq, "reason" AS reason,
122
+ ${timestampToMs('"parked_at"')} AS parked_at_ms
123
+ FROM ${parked} AS p
124
+ WHERE p."feed" = $1 AND p."sink" = $2
125
+ ORDER BY p."seq", p."key"`,
126
+ lockFeedExclusive: `SELECT 1 AS locked FROM ${generations}
127
+ WHERE "feed" = $1 FOR UPDATE`,
128
+ unparkOne: `DELETE FROM ${parked}
129
+ WHERE "feed" = $1 AND "sink" = $2 AND "key" = $3
130
+ RETURNING 1 AS unparked`,
131
+ restampRow: `UPDATE ${rows} SET "seq" = ${nextSeq}
132
+ WHERE "feed" = $1 AND "key" = $2`,
133
+ // Every sink REGISTERED on the feed counts — every cursor row, whatever
134
+ // generation its cursor was saved at — and the lowest of their cursors bounds
135
+ // what goes. A cursor at an older generation needs no arm of its own:
136
+ // `FeedSyncStore.ts`'s tombstone section owns why the minimum already holds it
137
+ // below every row, and this store keeps that section's premise, since a reset
138
+ // (`withFeed.ts`) moves the generation and empties the feed in one
139
+ // transaction and the one sequence never goes back. A sink with no cursor is
140
+ // resyncing from the origin and blocks collection outright, which `bool_or`
141
+ // says. No cursor row at all leaves both aggregates NULL, so the guard is NULL
142
+ // and nothing goes. The cursors are read in the delete's own statement
143
+ // snapshot, so a cursor saved after it can only be one that blocked here
144
+ // (nothing deleted) or one that moved up. A sink whose cursor row is CREATED
145
+ // after the snapshot is not counted, and need not be: that row is made when
146
+ // the sink takes its lease, before it reads the feed, and a feed holds one row
147
+ // per key, so a tombstone this collects had replaced its key's live row before
148
+ // such a sink could read anything.
149
+ collectTombstones: `WITH "low" AS (
150
+ SELECT min(c."seq") AS "seq",
151
+ bool_or(c."seq" IS NULL) AS "blocked"
152
+ FROM ${cursors} c
153
+ WHERE c."feed" = $1
154
+ )
155
+ DELETE FROM ${rows} r USING "low"
156
+ WHERE r."feed" = $1 AND r."doc" IS NULL
157
+ AND NOT "low"."blocked" AND r."seq" <= "low"."seq"
158
+ RETURNING 1 AS collected`,
159
+ // The transaction-scoped twin of `tryAdvisoryLock`, on the same key, so it
160
+ // conflicts with a session lock any advisory worker holds and is let go at
161
+ // commit or rollback without an unlock of its own.
162
+ tryAdvisoryXactLock: `SELECT pg_try_advisory_xact_lock(${advisoryKey}) AS taken`,
163
+ // A row whose expiry is later than the caller's now is a live lease row and
164
+ // stays. A holder with no expiry is an advisory one, and is deleted only
165
+ // because the caller holds the advisory lock, which proves its session gone.
166
+ // The test is in the delete itself, so a lease row taken after an earlier
167
+ // read is still refused here rather than deleted from under its worker.
168
+ deleteCursorUnlessLeased: `DELETE FROM ${cursors}
169
+ WHERE "feed" = $1 AND "sink" = $2
170
+ AND ("lease_expires_at" IS NULL
171
+ OR "lease_expires_at" <= ${msToTimestamp('$3')})
172
+ RETURNING 1 AS removed`,
173
+ cursorExists: `SELECT 1 AS present FROM ${cursors}
174
+ WHERE "feed" = $1 AND "sink" = $2`,
175
+ deleteSinkParked: `DELETE FROM ${parked} WHERE "feed" = $1 AND "sink" = $2
176
+ RETURNING 1 AS removed`,
177
+ selectHead: `SELECT COALESCE(max("seq"), 0)::text AS head
178
+ FROM ${rows} WHERE "feed" = $1`,
179
+ describeSinks: `SELECT c."sink" AS sink,
180
+ c."generation"::text AS generation,
181
+ c."seq"::text AS seq,
182
+ c."lease_holder" AS lease_holder,
183
+ ${timestampToMs('c."lease_expires_at"')} AS lease_expires_at_ms,
184
+ ${timestampToMs('c."last_advance_at"')} AS last_advance_at_ms,
185
+ (SELECT count(*) FROM ${rows} r
186
+ WHERE r."feed" = c."feed"
187
+ AND CASE WHEN c."seq" IS NULL THEN r."doc" IS NOT NULL
188
+ ELSE r."seq" > c."seq" END)::text AS pending
189
+ FROM ${cursors} c
190
+ WHERE c."feed" = $1
191
+ ORDER BY c."sink"`,
192
+ describeParked: `SELECT "sink" AS sink, "key" AS key, "seq"::text AS seq,
193
+ "reason" AS reason,
194
+ ${timestampToMs('"parked_at"')} AS parked_at_ms
195
+ FROM ${parked} AS p
196
+ WHERE p."feed" = $1
197
+ ORDER BY p."sink", p."seq", p."key"`,
198
+ // Taken when nobody holds it, when the holder's expiry has passed, or when the
199
+ // holder has no expiry — an ADVISORY holder — and the caller holds that
200
+ // sink's advisory lock for its own transaction (`$6`), which an advisory
201
+ // worker's live session would have refused, so the session that recorded the
202
+ // holder is gone. The caller holds that lock until this statement commits, so
203
+ // no advisory worker can take the lock and record itself in between.
204
+ takeLeaseRow: `INSERT INTO ${cursors} AS c ("feed", "sink", "lease_holder", "lease_expires_at")
205
+ VALUES ($1, $2, $3, ${msToTimestamp('$4')})
206
+ ON CONFLICT ("feed", "sink") DO UPDATE
207
+ SET "lease_holder" = excluded."lease_holder",
208
+ "lease_expires_at" = excluded."lease_expires_at"
209
+ WHERE c."lease_holder" IS NULL
210
+ OR (c."lease_expires_at" IS NOT NULL
211
+ AND c."lease_expires_at" <= ${msToTimestamp('$5')})
212
+ OR (c."lease_expires_at" IS NULL AND $6::boolean)
213
+ RETURNING 1 AS taken`,
214
+ // Never moves the expiry back: a renewal the worker abandoned at its timeout
215
+ // can still land after a later one, if its cancel came too late, and must not
216
+ // shorten the lease the later one recorded, which the worker's `ensureHeld`
217
+ // already counts on.
218
+ renewLeaseRow: `UPDATE ${cursors}
219
+ SET "lease_expires_at" = GREATEST("lease_expires_at", ${msToTimestamp('$4')})
220
+ WHERE "feed" = $1 AND "sink" = $2 AND "lease_holder" = $3
221
+ RETURNING 1 AS renewed`,
222
+ releaseLease: `UPDATE ${cursors}
223
+ SET "lease_holder" = NULL, "lease_expires_at" = NULL
224
+ WHERE "feed" = $1 AND "sink" = $2 AND "lease_holder" = $3`,
225
+ // Run on the SESSION that took the advisory lock, and proposes its row only
226
+ // while that session still holds the lock, so a claim from a session that has
227
+ // died — or let the lock go — since its try-lock records nothing, and cannot
228
+ // overwrite a successor that took the lock in between. Holding the lock proves
229
+ // that any ADVISORY holder recorded here (one with no expiry) is a dead
230
+ // session's, so that one is taken over. A lease-row holder whose expiry has not
231
+ // passed is live and is never taken over, as `takeLeaseRow` takes over an
232
+ // advisory holder only once its lock is free: whichever kind took the row
233
+ // first keeps it while it lives.
234
+ claimAdvisoryHolder: `INSERT INTO ${cursors} AS c ("feed", "sink", "lease_holder", "lease_expires_at")
235
+ SELECT $1::text, $2::text, $3::text, NULL::timestamptz
236
+ WHERE ${sessionHoldsLock('$5')}
237
+ ON CONFLICT ("feed", "sink") DO UPDATE
238
+ SET "lease_holder" = excluded."lease_holder",
239
+ "lease_expires_at" = NULL
240
+ WHERE c."lease_holder" IS NULL
241
+ OR c."lease_expires_at" IS NULL
242
+ OR c."lease_expires_at" <= ${msToTimestamp('$4')}
243
+ RETURNING 1 AS claimed`,
244
+ tryAdvisoryLock: `SELECT pg_try_advisory_lock(${advisoryKey}) AS taken`,
245
+ holdsAdvisoryLock: `SELECT ${sessionHoldsLock('$1')} AS held`,
246
+ advisoryUnlock: `SELECT pg_advisory_unlock(${advisoryKey}) AS released`,
247
+ bindFeed: `INSERT INTO ${generations} AS g
248
+ ("feed", "generation", "checkpoint_projection", "checkpoint_partition")
249
+ VALUES ($1, 0, $2, $3)
250
+ ON CONFLICT ("feed") DO UPDATE
251
+ SET "checkpoint_projection" = COALESCE(g."checkpoint_projection", excluded."checkpoint_projection"),
252
+ "checkpoint_partition" = COALESCE(g."checkpoint_partition", excluded."checkpoint_partition")
253
+ RETURNING "checkpoint_projection" AS projection,
254
+ "checkpoint_partition" AS partition`,
255
+ bumpGeneration: `INSERT INTO ${generations} AS g ("feed", "generation")
256
+ VALUES ($1, 1)
257
+ ON CONFLICT ("feed") DO UPDATE
258
+ SET "generation" = g."generation" + 1`,
259
+ deleteFeedRows: `DELETE FROM ${rows} WHERE "feed" = $1`
260
+ };
261
+ };
262
+ //# sourceMappingURL=statements.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"statements.js","names":["quoteQualified","sequenceLiteral","compileFeedStatements","names","rows","rowsTable","generations","generationsTable","cursors","cursorsTable","parked","parkedTable","nextSeq","msToTimestamp","param","timestampToMs","column","advisoryKeyOf","advisoryKey","sessionHoldsLock","ensureGeneration","lockFeedShared","upsertRows","tombstoneRows","selectGeneration","changesAfter","selectCursor","casCursor","clearParked","unparkKeys","parkKeys","selectParked","lockFeedExclusive","unparkOne","restampRow","collectTombstones","tryAdvisoryXactLock","deleteCursorUnlessLeased","cursorExists","deleteSinkParked","selectHead","describeSinks","describeParked","takeLeaseRow","renewLeaseRow","releaseLease","claimAdvisoryHolder","tryAdvisoryLock","holdsAdvisoryLock","advisoryUnlock","bindFeed","bumpGeneration","deleteFeedRows"],"sources":["../../../src/internal/statements.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}