@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,138 @@
1
+ /**
2
+ * The sync worker's lease on Postgres, in the two kinds `FeedSyncStore`'s doc
3
+ * describes, chosen in config to suit the infrastructure in front of the
4
+ * database.
5
+ *
6
+ * ## Why there are two
7
+ *
8
+ * A worker lease is what keeps a feed's sends to a sink in order: a sink has no
9
+ * transaction, so a rival worker's stale send lands before any compare-and-set can
10
+ * catch it (ADR-0019 §5). The two kinds trade the same two things against each
11
+ * other — what the connection must be, and how long a lapsed worker can go on
12
+ * sending — and neither wins on both.
13
+ *
14
+ * - **`leaseRow`, the default.** The holder and an expiry live on the sink's
15
+ * cursor row, taken with a guarded upsert (only when free, expired, or held by
16
+ * an advisory worker whose session is gone; see below) and renewed by a
17
+ * heartbeat forked into the lease's scope. Every statement is an ordinary short
18
+ * one, and the take one short transaction, so it works through a TRANSACTION
19
+ * POOLER, which drops session state and so rules out a session lock, and it
20
+ * needs no reserved connection. Its cost is a window. `ensureHeld` fails a
21
+ * MARGIN before the stored expiry: once the last successful renewal is
22
+ * `ttl - margin` old by the worker's own `Clock`, that renewal's expiry, which is
23
+ * when a rival may take the row, is at most `margin` away. So a call to the sink
24
+ * that `ensureHeld` let through, and that lands within `margin` of that check,
25
+ * pauses included, lands before any rival can hold the lease, however its
26
+ * renewals have been failing meanwhile, as long as the two workers' clocks
27
+ * agree. A call that outlasts the margin, whether it is slow or its worker
28
+ * pauses (a long GC pause, a network stall), can land after a rival took over:
29
+ * ONE stale call, since the next `ensureHeld` fails; `FeedSyncStore.ts` owns what
30
+ * that call costs. The margin is `ttl / 3` unless configured, and must leave the
31
+ * heartbeat room under the lease (`heartbeat + margin < ttl`), or `ensureHeld`
32
+ * would fail between two renewals that both landed at once. Each renewal is
33
+ * bounded by the heartbeat and the next starts on the next tick, and the age
34
+ * `ensureHeld` judges runs from when a renewal STARTED. So a renewal that lands
35
+ * is at most `heartbeat` late, and the worst age `ensureHeld` meets is
36
+ * `heartbeat` plus that latency with every renewal landing, and `2 * heartbeat`
37
+ * plus it with one lost. A worker therefore goes on sending, without a single
38
+ * failed `ensureHeld`, through renewals that are slow when
39
+ * `2 * heartbeat + margin <= ttl`, and through one that fails or hangs when
40
+ * `3 * heartbeat + margin <= ttl`. The defaults satisfy the second; the
41
+ * construction check enforces only the first's zero-latency form, so a tighter
42
+ * config is accepted and trades those guarantees for a shorter `ttl`.
43
+ * - **`advisoryLock`.** A session `pg_try_advisory_lock` on a connection RESERVED
44
+ * from a client the consumer dedicates to it, keyed by a hash of the prefix, the
45
+ * feed and the sink. The lock goes when the session goes, and `ensureHeld` asks
46
+ * that same session, before every call that changes the sink, whether it still
47
+ * holds the lock; any failure to answer is a lost lease. So a process pause
48
+ * opens no stale window, since the lock survives it. What a check cannot see is
49
+ * the session dying AFTER it: the call it let through is already under way and
50
+ * runs to its end, so it can land as late as its whole duration after the session
51
+ * died, and after a rival has taken the lock and sent. That is still ONE stale
52
+ * call, since the next `ensureHeld` asks a session that is gone, but a sink call
53
+ * can be long — a resync's `publish` most of all. The price is a connection per
54
+ * running worker, and a connection that is genuinely a SESSION: a transaction
55
+ * pooler would hand the lock's session to somebody else between statements. A
56
+ * client this package can recognise as one is refused at construction; see
57
+ * {@link pooledConnectionFault} for what it can and cannot recognise.
58
+ *
59
+ * ## Fencing is the same for both
60
+ *
61
+ * Both kinds record the holder on the cursor row, and the cursor compare-and-set
62
+ * requires it, so a worker whose lease has gone cannot move the cursor whichever
63
+ * kind it held. Mixing the two kinds on one feed and sink fails safe, because
64
+ * neither takes the row from a LIVE holder of the other. A lease-row worker takes
65
+ * over an advisory holder (one with no expiry to lapse) only while it holds that
66
+ * sink's advisory lock for its own transaction, which a live advisory session
67
+ * refuses; so a crashed advisory worker, whose scope never closed to clear the row,
68
+ * does not leave the sink unleasable by every lease-row worker after it. An
69
+ * advisory worker that wins the lock but finds a lease row unexpired on the row lets
70
+ * the lock go and fails `LeaseHeld`. Whichever took the row first keeps it while it
71
+ * lives. The advisory worker likewise takes over an ADVISORY holder recorded on the
72
+ * row, because holding the lock proves that session is gone; it records itself
73
+ * from the session that holds the lock, and only while it still does, so a session
74
+ * that died after its try-lock cannot overwrite the successor that took the lock
75
+ * from it.
76
+ */
77
+ import { type FeedLease, type FeedSinkId, type FeedUnreadable, type FeedWriteFailed, LeaseHeld } from '@kairos-es/feed';
78
+ import { Duration, Effect, type Scope } from 'effect';
79
+ import type { SqlClient } from 'effect/sql';
80
+ /** The lease kind, as config carries it. See the module doc. */
81
+ export type FeedLeaseConfig = {
82
+ readonly _tag: 'LeaseRow';
83
+ /** How long a lease lasts without a renewal. */
84
+ readonly ttl: Duration.Input;
85
+ /** How often the holder renews it; shorter than `ttl`. */
86
+ readonly heartbeat: Duration.Input;
87
+ /**
88
+ * How long before the stored expiry `ensureHeld` starts failing: a sink call
89
+ * that takes less than this cannot land after a rival took the lease. See
90
+ * the module doc. `ttl / 3` when absent; `heartbeat + margin` must be under
91
+ * `ttl`.
92
+ */
93
+ readonly margin?: Duration.Input;
94
+ } | {
95
+ readonly _tag: 'AdvisoryLock';
96
+ /**
97
+ * A client used for nothing but leases, each lease reserving one connection
98
+ * from it for its lifetime. Its lifecycle is the consumer's, exactly as the
99
+ * main client's is.
100
+ */
101
+ readonly connection: SqlClient.SqlClient;
102
+ };
103
+ /** The default lease length. */
104
+ export declare const DEFAULT_LEASE_TTL: Duration.Duration;
105
+ /**
106
+ * The default renewal interval: a fifth of the lease. With the default margin, a
107
+ * third, `3 * heartbeat + margin` is under the `ttl`, so a worker goes on sending,
108
+ * with no failed `ensureHeld`, through one renewal that fails or hangs. However
109
+ * many fail, it stops sending a margin before its lease could lapse. The module
110
+ * doc has the arithmetic.
111
+ */
112
+ export declare const DEFAULT_LEASE_HEARTBEAT: Duration.Duration;
113
+ /** A lease row, the default; see the module doc. */
114
+ export declare const leaseRow: (options?: {
115
+ readonly ttl?: Duration.Input;
116
+ readonly heartbeat?: Duration.Input;
117
+ readonly margin?: Duration.Input;
118
+ }) => FeedLeaseConfig;
119
+ /** A session advisory lock on a dedicated connection; see the module doc. */
120
+ export declare const advisoryLock: (options: {
121
+ readonly connection: SqlClient.SqlClient;
122
+ }) => FeedLeaseConfig;
123
+ /**
124
+ * Why `client` cannot carry a session lock, when this package can tell, or
125
+ * `undefined`.
126
+ *
127
+ * What it can tell is limited to what a client says about where it connects,
128
+ * which `@effect/sql-pg`'s client exposes as `config` and a generic `SqlClient`
129
+ * does not. It recognises the two pooler conventions that show in an address: a
130
+ * Neon pooled endpoint (a host containing `-pooler`), and PgBouncer's default
131
+ * port, 6432, or a `pgbouncer=true` URL parameter. A pooler on any other address,
132
+ * or behind a client that exposes nothing, passes; that is a wiring the consumer
133
+ * owns. The URL is read only to judge it and is never repeated in the message.
134
+ */
135
+ export declare const pooledConnectionFault: (client: SqlClient.SqlClient) => string | undefined;
136
+ /** How a sync store takes a lease. Package-internal. */
137
+ export type AcquireLease = (id: FeedSinkId) => Effect.Effect<FeedLease, LeaseHeld | FeedUnreadable | FeedWriteFailed, Scope.Scope>;
138
+ //# sourceMappingURL=lease.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lease.d.ts","sourceRoot":"","sources":["../../src/lease.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AACH,OAAO,EACL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,SAAS,EAGV,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAEL,QAAQ,EACR,MAAM,EAKN,KAAK,KAAK,EACX,MAAM,QAAQ,CAAA;AACf,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAG3C,gEAAgE;AAChE,MAAM,MAAM,eAAe,GACvB;IACE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,gDAAgD;IAChD,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,KAAK,CAAA;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,KAAK,CAAA;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;CACjC,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC,SAAS,CAAA;CACzC,CAAA;AAEL,gCAAgC;AAChC,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAExE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,QAA8B,CAAA;AAE7E,oDAAoD;AACpD,eAAO,MAAM,QAAQ,GACnB,UAAS;IACP,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;CAC5B,KACL,eAKD,CAAA;AAEF,6EAA6E;AAC7E,eAAO,MAAM,YAAY,GAAI,SAAS;IACpC,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC,SAAS,CAAA;CACzC,KAAG,eAGF,CAAA;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GAChC,QAAQ,SAAS,CAAC,SAAS,KAC1B,MAAM,GAAG,SAwCX,CAAA;AAED,wDAAwD;AACxD,MAAM,MAAM,YAAY,GAAG,CACzB,EAAE,EAAE,UAAU,KACX,MAAM,CAAC,MAAM,CAChB,SAAS,EACT,SAAS,GAAG,cAAc,GAAG,eAAe,EAC5C,KAAK,CAAC,KAAK,CACZ,CAAA"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The Postgres `FeedSyncStore`: every sink's cursor, its parked list and its
3
+ * worker lease, kept in the source database beside the feed rows (ADR-0019 §10).
4
+ *
5
+ * `@kairos-es/feed`'s `FeedSyncStore.ts` owns the contract, and the in-memory feed
6
+ * is the oracle this store is held to; what is here is how each atomic step the
7
+ * contract names becomes one transaction or one statement.
8
+ *
9
+ * - `casCursor` is ONE transaction: the guarded, holder-fenced cursor update,
10
+ * stamped with its `lastAdvance` from this process's `Clock`, and the parked
11
+ * list moved with it. A guard that loses fails `CursorSuperseded` inside the
12
+ * transaction, which rolls back the parked changes with it, so a park can never
13
+ * outlive a refused advance.
14
+ * - `retryParked` is one transaction that takes the feed lock exclusively before it
15
+ * re-stamps the row, so its new `seq` cannot commit out of order with the
16
+ * materialisation's own writes (`internal/statements.ts` owns that argument).
17
+ * - `collectTombstones` is one statement that reads every registered sink's cursor
18
+ * and deletes below the lowest, or nothing while any of them has no cursor, so
19
+ * no cursor can move between the two.
20
+ * - `removeSink` is one transaction that first takes the sink's advisory lock for
21
+ * its own length, which an advisory worker's session lock refuses, then deletes
22
+ * the cursor row only when no live lease row holds it, in the same statement as
23
+ * that test, and the parks after it. Both tests are therefore made whichever
24
+ * lease kind this store was built with: a sink can be leased by either kind
25
+ * (`lease.ts` owns how the two coexist), and the row alone cannot say whether
26
+ * an advisory holder's session is alive.
27
+ * - `describe` is three reads, each its own snapshot, so a figure in it can be a
28
+ * batch older or newer than the next. It is a report for an operator, and no
29
+ * decision is taken on it.
30
+ *
31
+ * One store serves every feed in its prefix: unlike the in-memory feed, whose
32
+ * store belongs to one feed, a SQL store's state is keyed by feed name in shared
33
+ * tables, so a single store value is what every worker over the prefix takes.
34
+ *
35
+ * Infrastructure faults are `FeedUnreadable` on a read and `FeedWriteFailed` on a
36
+ * write, never defects, so the driver's supervision backs off and tries again.
37
+ */
38
+ import { type FeedSyncStore } from '@kairos-es/feed';
39
+ import { Effect } from 'effect';
40
+ import { SqlClient } from 'effect/sql';
41
+ import type { FeedPostgresConfig } from './config.js';
42
+ import { type FeedLeaseConfig } from './lease.js';
43
+ /** Options for {@link makePostgresFeedSyncStore}. */
44
+ export interface PostgresFeedSyncStoreOptions {
45
+ /** The `{ schema, tablePrefix }` pair the feeds were written under. */
46
+ readonly config?: FeedPostgresConfig;
47
+ /** The lease kind; `leaseRow()` with its defaults when absent. */
48
+ readonly lease?: FeedLeaseConfig;
49
+ }
50
+ /**
51
+ * Build the sync store over the ambient `SqlClient`. Dies at construction on a
52
+ * malformed config, a lease whose heartbeat is not shorter than its ttl or leaves
53
+ * no room for its margin, or an `advisoryLock` connection recognisably pooled.
54
+ * `ensureFeedSchema` must have run.
55
+ */
56
+ export declare const makePostgresFeedSyncStore: (options?: PostgresFeedSyncStoreOptions) => Effect.Effect<FeedSyncStore, never, SqlClient.SqlClient>;
57
+ //# sourceMappingURL=syncStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"syncStore.d.ts","sourceRoot":"","sources":["../../src/syncStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAML,KAAK,aAAa,EAOnB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAmB,MAAM,EAAU,MAAM,QAAQ,CAAA;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAOlD,OAAO,EAEL,KAAK,eAAe,EAGrB,MAAM,SAAS,CAAA;AAEhB,qDAAqD;AACrD,MAAM,WAAW,4BAA4B;IAC3C,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAA;IACpC,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAA;CACjC;AA0DD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GACpC,UAAS,4BAAiC,KACzC,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,KAAK,EAAE,SAAS,CAAC,SAAS,CAwMtD,CAAA"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `withFeed` — a `ProjectionStore` decorated so that resetting the checkpoint a
3
+ * feed is bound to also empties the feed and moves its generation on, in ONE
4
+ * transaction.
5
+ *
6
+ * ## Why a decorator
7
+ *
8
+ * Nothing in the library rebuilds a read model; a rebuild is an application
9
+ * resetting a checkpoint and letting the runner replay. A feed has to know when
10
+ * that happens, because a sink holding the old copy must be rebuilt from the
11
+ * replay rather than patched by it: a record the replay never writes again would
12
+ * otherwise survive in the sink. The reset is the one moment both facts are true,
13
+ * so the generation bump rides it.
14
+ *
15
+ * What it adds is the FEED's half of a rebuild and nothing more. A rebuild resets
16
+ * the checkpoint, drops the view, and starts again from `ORIGIN`; this resets the
17
+ * checkpoint and the feed, and the view is still the consumer's to drop. So
18
+ * rebuilding a DURABLE view means emptying it yourself before the replay, exactly
19
+ * as the port's own `resetCheckpoint` leaves it. Skip that and a row the replay
20
+ * never writes again survives in the view, and a read model whose `apply` reads
21
+ * its view back (a running count, say) builds on the old rows, which the feed
22
+ * then mirrors to every sink.
23
+ *
24
+ * The decorator keeps the port's three methods and replaces only
25
+ * `resetCheckpoint`, so it works through `forKey` exactly as the store does, and
26
+ * nothing about the read side has to know a feed exists.
27
+ *
28
+ * ## One transaction, and the client it needs
29
+ *
30
+ * The reset bumps the generation, deletes the checkpoint row through the store's
31
+ * own `resetCheckpoint`, and deletes the feed's rows, all inside one
32
+ * `withTransaction` on the client this decorator was built over. The store's own
33
+ * statement joins that transaction only when the store was built from the SAME
34
+ * client instance (ADR-0018 §3), which `@kairos-es/read-postgres`'s
35
+ * `makeSqlProjectionStore` is when both are built under one `SqlClient`. Build
36
+ * them apart and the checkpoint delete commits on its own: the reset still
37
+ * happens, but not atomically with the bump.
38
+ *
39
+ * The generation is bumped FIRST. That takes the feed lock exclusively, so a
40
+ * materialisation commit in flight finishes before the reset proceeds, and one
41
+ * that starts after it waits; and it takes the locks in the same order a commit
42
+ * does (the feed, then the checkpoint), so the two cannot deadlock. A commit that
43
+ * was mid-flight with the old checkpoint then loses its guard and rolls its feed
44
+ * rows back with its view.
45
+ *
46
+ * ## The binding
47
+ *
48
+ * A feed serves ONE materialisation, because one writer is what keeps its `seq`
49
+ * ascending in commit order. The binding is recorded on the feed's generations
50
+ * row the first time the feed is decorated, and checked at every construction
51
+ * after that, so a feed bound to another checkpoint key is refused across
52
+ * processes, not only within one. A refusal is a defect, as the read side's
53
+ * construction gate's are.
54
+ *
55
+ * The decorated store must be DURABLE. This feed is, and the feed's rows are
56
+ * written inside the store's commit transaction: an ephemeral store opens no
57
+ * transaction for them to join.
58
+ */
59
+ import type { FeedWriteFailed } from '@kairos-es/feed';
60
+ import { type CheckpointKey, type ProjectionStore } from '@kairos-es/read';
61
+ import { Effect } from 'effect';
62
+ import { SqlClient } from 'effect/sql';
63
+ import { type PostgresFeed } from './feed.js';
64
+ /**
65
+ * `store`, with `resetCheckpoint` for `key` also emptying `feed` and moving its
66
+ * generation on; see the module doc. Every other key passes through untouched.
67
+ */
68
+ export declare const withFeed: <D>(store: ProjectionStore, feed: PostgresFeed<D>, key: CheckpointKey) => Effect.Effect<ProjectionStore, FeedWriteFailed, SqlClient.SqlClient>;
69
+ //# sourceMappingURL=withFeed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withFeed.d.ts","sourceRoot":"","sources":["../../src/withFeed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,eAAe,EAErB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtC,OAAO,EAAa,KAAK,YAAY,EAAE,MAAM,QAAQ,CAAA;AAMrD;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,EACxB,OAAO,eAAe,EACtB,MAAM,YAAY,CAAC,CAAC,CAAC,EACrB,KAAK,aAAa,KACjB,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,eAAe,EAAE,SAAS,CAAC,SAAS,CAmElE,CAAA"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Public configuration for the Postgres change feed.
3
+ *
4
+ * A feed backend is defined by its `{ schema, tablePrefix }` naming pair alone:
5
+ * every object it owns is DERIVED from it (`internal/ddl.ts`), so one config
6
+ * value is one isolated set of feed tables, cohabiting per prefix beside the
7
+ * event store's objects and `@kairos-es/read-postgres`'s checkpoint table. Pass
8
+ * the same pair to all three and one prefix names one complete world: the log,
9
+ * the checkpoints, and the feeds the read models write.
10
+ *
11
+ * It is a `Schema` rather than a bare interface so that a malformed value fails
12
+ * LOUDLY at construction rather than obscurely at the first statement: an empty or
13
+ * dotted atom (a dot would break the qualified-name split), or a pair whose
14
+ * derived names would exceed Postgres's 63-byte identifier limit and be silently
15
+ * truncated. The shape and its checks are `@kairos-es/read-postgres`'s, written
16
+ * again here rather than imported, because each backend measures only the names it
17
+ * creates and a sibling backend's internals are not a public surface.
18
+ */
19
+ import { Schema } from 'effect';
20
+ import { oversizedIdentifier } from "./internal/ddl.js";
21
+ /** A single identifier atom: non-empty and dot-free. */
22
+ const IdentifierAtom = /*#__PURE__*/Schema.String.check(/*#__PURE__*/Schema.isNonEmpty(), /*#__PURE__*/Schema.isPattern(/^[^.]+$/, {
23
+ identifier: 'PostgresIdentifierAtom',
24
+ description: 'a non-empty identifier atom containing no "."'
25
+ }));
26
+ /**
27
+ * Configuration for the feed's tables.
28
+ *
29
+ * - `schema` — the Postgres schema that owns them (default `public`).
30
+ * - `tablePrefix` — the stem every name derives from (default `dcb_events`,
31
+ * giving `dcb_events_feed_rows` and its siblings).
32
+ */
33
+ export const FeedPostgresConfig = /*#__PURE__*/Schema.Struct({
34
+ schema: Schema.optionalKey(IdentifierAtom),
35
+ tablePrefix: Schema.optionalKey(IdentifierAtom)
36
+ }).check(/*#__PURE__*/Schema.makeFilter(config => oversizedIdentifier(config)));
37
+ /** Decode a config, throwing a `SchemaError` on any violation. */
38
+ export const decodeFeedPostgresConfig = /*#__PURE__*/Schema.decodeUnknownSync(FeedPostgresConfig);
39
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","names":["Schema","oversizedIdentifier","IdentifierAtom","String","check","isNonEmpty","isPattern","identifier","description","FeedPostgresConfig","Struct","schema","optionalKey","tablePrefix","makeFilter","config","decodeFeedPostgresConfig","decodeUnknownSync"],"sources":["../../src/config.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;AAkBA,SAASA,MAAM,QAAQ,QAAQ;AAC/B,SAASC,mBAAmB,QAAQ,mBAAgB;AAEpD;AACA,MAAMC,cAAc,gBAAGF,MAAM,CAACG,MAAM,CAACC,KAAK,cACxCJ,MAAM,CAACK,UAAU,EAAE,eACnBL,MAAM,CAACM,SAAS,CAAC,SAAS,EAAE;EAC1BC,UAAU,EAAE,wBAAwB;EACpCC,WAAW,EAAE;CACd,CAAC,CACH;AAED;;;;;;;AAOA,OAAO,MAAMC,kBAAkB,gBAAGT,MAAM,CAACU,MAAM,CAAC;EAC9CC,MAAM,EAAEX,MAAM,CAACY,WAAW,CAACV,cAAc,CAAC;EAC1CW,WAAW,EAAEb,MAAM,CAACY,WAAW,CAACV,cAAc;CAC/C,CAAC,CAACE,KAAK,cAACJ,MAAM,CAACc,UAAU,CAAEC,MAAM,IAAKd,mBAAmB,CAACc,MAAM,CAAC,CAAC,CAAC;AAGpE;AACA,OAAO,MAAMC,wBAAwB,gBACnChB,MAAM,CAACiB,iBAAiB,CAACR,kBAAkB,CAAC","ignoreList":[]}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `ensureFeedSchema` — the feed's idempotent DDL, as a payload for the consumer's
3
+ * migrator rather than a migrator of its own, exactly as `ensureCheckpointSchema`
4
+ * is for the read side and `ensureSchema` for the event store.
5
+ *
6
+ * It requires the GENERIC `SqlClient`, so it drops into an existing migrator:
7
+ *
8
+ * ```ts
9
+ * Migrator.fromRecord({
10
+ * '0003_kairos_feed': ensureFeedSchema(config),
11
+ * })
12
+ * ```
13
+ *
14
+ * or runs once at boot. Every statement is `IF NOT EXISTS`, so running it again
15
+ * against existing tables keeps every row. Nothing in this package performs DDL at
16
+ * runtime: a feed cannot conjure its own tables, so schema ownership stays with
17
+ * whoever owns the migrations.
18
+ */
19
+ import { Effect } from 'effect';
20
+ import { SqlClient } from 'effect/sql';
21
+ import { decodeFeedPostgresConfig } from "./config.js";
22
+ import { resolveFeedNames, runFeedDdl } from "./internal/ddl.js";
23
+ /** The feed tables' migration payload for `config`. */
24
+ export const ensureFeedSchema = (config = {}) => Effect.gen(function* () {
25
+ const sql = yield* SqlClient.SqlClient;
26
+ const names = resolveFeedNames(decodeFeedPostgresConfig(config));
27
+ yield* Effect.all(runFeedDdl(sql, names), {
28
+ discard: true
29
+ });
30
+ });
31
+ //# sourceMappingURL=ensureFeedSchema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensureFeedSchema.js","names":["Effect","SqlClient","decodeFeedPostgresConfig","resolveFeedNames","runFeedDdl","ensureFeedSchema","config","gen","sql","names","all","discard"],"sources":["../../src/ensureFeedSchema.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;AAkBA,SAASA,MAAM,QAAQ,QAAQ;AAC/B,SAASC,SAAS,QAAuB,YAAY;AACrD,SAASC,wBAAwB,QAAiC,aAAU;AAC5E,SAASC,gBAAgB,EAAEC,UAAU,QAAQ,mBAAgB;AAE7D;AACA,OAAO,MAAMC,gBAAgB,GAAGA,CAC9BC,MAAA,GAA6B,EAAE,KAE/BN,MAAM,CAACO,GAAG,CAAC,aAAS;EAClB,MAAMC,GAAG,GAAG,OAAOP,SAAS,CAACA,SAAS;EACtC,MAAMQ,KAAK,GAAGN,gBAAgB,CAACD,wBAAwB,CAACI,MAAM,CAAC,CAAC;EAChE,OAAON,MAAM,CAACU,GAAG,CAACN,UAAU,CAACI,GAAG,EAAEC,KAAK,CAAC,EAAE;IAAEE,OAAO,EAAE;EAAI,CAAE,CAAC;AAC9D,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The Postgres feed's two faces: the `BatchedFeedWriter` a read model's `apply`
3
+ * writes through, and the `FeedSource` a sync worker tails.
4
+ *
5
+ * ## The writer rides the read model's own client
6
+ *
7
+ * A feed row must exist if and only if the view write and the checkpoint advance
8
+ * that produced it landed (ADR-0019 §2). `@kairos-es/read-postgres`'s `commit` is
9
+ * one transaction over the client the store was built from, and `effect/sql`
10
+ * carries that transaction in the Effect CONTEXT under a key that belongs to that
11
+ * one client instance (ADR-0018 §3). So the writer does not capture a client: it
12
+ * reads `SqlClient` from the context AT WRITE TIME, which inside `commit`'s
13
+ * `viewWrites` is the read model's own, and its statements join the open
14
+ * transaction and roll back with it on a lost guard. That is why `SqlClient` sits
15
+ * in the writer's `RW` and not in the factory's requirements alone. A write
16
+ * through any OTHER client instance is not bracketed, and the exactly-once
17
+ * property does not extend to it.
18
+ *
19
+ * ## What one `write` sends
20
+ *
21
+ * The body's upserts and removes are buffered, last write per key winning, and
22
+ * land as at most three statements whatever the batch size: the feed lock (see
23
+ * `internal/statements.ts` for why the writer takes it), one upsert of every
24
+ * changed document and one tombstone of every removed key. The upsert writes
25
+ * nothing for a document equal to the stored one, compared as `jsonb`, so key
26
+ * order is no change; the tombstone writes nothing for a key that is absent or
27
+ * already a tombstone.
28
+ *
29
+ * Each document is encoded through its `Schema` before anything is sent. A
30
+ * document that fails to encode is a DEFECT in the read model, not a fault of the
31
+ * feed and not something a sink should ever be asked to refuse.
32
+ *
33
+ * ## The source
34
+ *
35
+ * `changesAfter` pages the feed's rows by `seq`, tombstones included, and decodes
36
+ * each document through the same `Schema`. A stored document that no longer
37
+ * decodes is a defect too: it means the schema changed without the feed being
38
+ * rebuilt, which retrying cannot mend.
39
+ *
40
+ * `listen` is optional and is a WAKE only. This package takes no reserved
41
+ * connection for a `LISTEN` of its own, as `@kairos-es/read-postgres` takes none,
42
+ * so the poll is the guarantee. {@link eventStoreWake} offers the event store's
43
+ * `subscribe` as a wake: it fires when an event is appended, which is BEFORE the
44
+ * projection has applied it and written the feed, so a woken read may find nothing
45
+ * new and the poll picks the row up. It shortens the wait; it never decides
46
+ * correctness.
47
+ */
48
+ import { DcbEventStore, Position } from '@kairos-es/core';
49
+ import { FeedKey, FeedSeq, Generation } from '@kairos-es/feed';
50
+ import { Effect, Option, Schema, Stream } from 'effect';
51
+ import { SqlClient } from 'effect/sql';
52
+ import { bigintOf, feedContext, unreadable, writeFailed } from "./internal/context.js";
53
+ /** The context a feed was built over, for `withFeed`. */
54
+ const contexts = /*#__PURE__*/new WeakMap();
55
+ /** @internal */
56
+ export const contextOf = feed => contexts.get(feed);
57
+ /**
58
+ * Build a feed over the ambient `SqlClient`. Creates the feed's generations row
59
+ * if it is absent, at generation 0, so the writer's feed lock always has a row to
60
+ * take; that is the only write it makes. `ensureFeedSchema` must have run first.
61
+ */
62
+ export const makePostgresFeed = options => Effect.gen(function* () {
63
+ const context = feedContext(options.config ?? {});
64
+ const {
65
+ statements
66
+ } = context;
67
+ const feed = options.feed;
68
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
69
+ const encode = Schema.encodeEffect(options.doc);
70
+ const decode = Schema.decodeUnknownEffect(options.doc);
71
+ yield* sql.unsafe(statements.ensureGeneration, [feed]).pipe(Effect.mapError(writeFailed(`creating the generation of feed '${feed}'`)));
72
+ const write = (at, body) => Effect.gen(function* () {
73
+ const buffer = new Map();
74
+ const set = (key, doc) => Effect.sync(() => {
75
+ buffer.delete(key);
76
+ buffer.set(key, doc);
77
+ });
78
+ const result = yield* body({
79
+ upsert: (key, doc) => set(key, Option.some(doc)),
80
+ remove: key => set(key, Option.none())
81
+ });
82
+ if (buffer.size === 0) {
83
+ return result;
84
+ }
85
+ const upserts = [];
86
+ const removes = [];
87
+ for (const [key, doc] of buffer) {
88
+ if (Option.isSome(doc)) {
89
+ upserts.push({
90
+ key,
91
+ doc: yield* Effect.orDie(encode(doc.value))
92
+ });
93
+ } else {
94
+ removes.push(key);
95
+ }
96
+ }
97
+ const upsertJson = upserts.length === 0 ? undefined : yield* Effect.try({
98
+ try: () => JSON.stringify(upserts),
99
+ catch: cause => new Error(`feed '${feed}': an encoded document is not JSON-serialisable`, {
100
+ cause
101
+ })
102
+ }).pipe(Effect.orDie);
103
+ // The read model's own client, from the context at write time; see the
104
+ // module doc. Transform-free, for the same reason the read side's store
105
+ // is: the copy keeps the original's transaction key.
106
+ const client = (yield* SqlClient.SqlClient).withoutTransforms();
107
+ const position = at.toString();
108
+ yield* client.withTransaction(Effect.gen(function* () {
109
+ yield* client.unsafe(statements.lockFeedShared, [feed]);
110
+ if (upsertJson !== undefined) {
111
+ yield* client.unsafe(statements.upsertRows, [feed, position, upsertJson]);
112
+ }
113
+ if (removes.length > 0) {
114
+ yield* client.unsafe(statements.tombstoneRows, [feed, position, JSON.stringify(removes)]);
115
+ }
116
+ })).pipe(Effect.mapError(writeFailed(`writing feed '${feed}'`)));
117
+ return result;
118
+ });
119
+ const decodeRow = row => Effect.gen(function* () {
120
+ // Bound to a `const` so the `null` check narrows inside the `try` below.
121
+ const stored = row.doc;
122
+ const doc = stored === null ? Option.none() : Option.some(yield* Effect.orDie(Effect.flatMap(Effect.try({
123
+ try: () => JSON.parse(stored),
124
+ catch: cause => new Error(`feed '${feed}': a stored document is not JSON`, {
125
+ cause
126
+ })
127
+ }), json => decode(json))));
128
+ return {
129
+ key: FeedKey.make(row.key),
130
+ doc,
131
+ seq: FeedSeq.make(bigintOf(row.seq)),
132
+ eventPosition: Position.make(bigintOf(row.event_position))
133
+ };
134
+ });
135
+ const source = {
136
+ feed,
137
+ generation: sql.unsafe(statements.selectGeneration, [feed]).pipe(Effect.mapError(unreadable(`reading the generation of feed '${feed}'`)), Effect.map(rows => {
138
+ const stored = rows[0]?.generation;
139
+ return Generation.make(stored === undefined ? 0n : bigintOf(stored));
140
+ })),
141
+ changesAfter: (after, limit) => sql.unsafe(statements.changesAfter, [feed, after.toString(), limit]).pipe(Effect.mapError(unreadable(`reading feed '${feed}'`)), Effect.flatMap(rows => Effect.forEach(rows, decodeRow))),
142
+ ...(options.listen === undefined ? {} : {
143
+ listen: options.listen
144
+ })
145
+ };
146
+ const result = {
147
+ feed,
148
+ write,
149
+ source
150
+ };
151
+ contexts.set(result, context);
152
+ return result;
153
+ });
154
+ /**
155
+ * A wake from the event store: one element per event matching `query` appended
156
+ * after the moment the stream is subscribed, and nothing else. Pass it as
157
+ * `listen`. Early by the projection's own lag, as the module doc says, so the
158
+ * worker's poll still bounds the latency; `query` should be the read model's own,
159
+ * so events it never applies wake nothing.
160
+ */
161
+ export const eventStoreWake = query => Effect.gen(function* () {
162
+ const store = yield* DcbEventStore;
163
+ const wake = Stream.unwrap(Effect.map(store.read(query), response => store.subscribe(query, response.head))).pipe(Stream.scoped, Stream.as(undefined));
164
+ return wake;
165
+ });
166
+ //# sourceMappingURL=feed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feed.js","names":["DcbEventStore","Position","FeedKey","FeedSeq","Generation","Effect","Option","Schema","Stream","SqlClient","bigintOf","feedContext","unreadable","writeFailed","contexts","WeakMap","contextOf","feed","get","makePostgresFeed","options","gen","context","config","statements","sql","withoutTransforms","encode","encodeEffect","doc","decode","decodeUnknownEffect","unsafe","ensureGeneration","pipe","mapError","write","at","body","buffer","Map","set","key","sync","delete","result","upsert","some","remove","none","size","upserts","removes","isSome","push","orDie","value","upsertJson","length","undefined","try","JSON","stringify","catch","cause","Error","client","position","toString","withTransaction","lockFeedShared","upsertRows","tombstoneRows","decodeRow","row","stored","flatMap","parse","json","make","seq","eventPosition","event_position","source","generation","selectGeneration","map","rows","changesAfter","after","limit","forEach","listen","eventStoreWake","query","store","wake","unwrap","read","response","subscribe","head","scoped","as"],"sources":["../../src/feed.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @kairos-es/feed-postgres — the Postgres change feed: the feed tables and their
3
+ * idempotent DDL, the `FeedWriter` that rides a read model's own `SqlClient`
4
+ * inside its commit, the `FeedSource`, the `FeedSyncStore` with its worker lease,
5
+ * and the store decorator that moves the feed's generation on a reset
6
+ * (ADR-0019).
7
+ *
8
+ * A package of its own rather than part of `@kairos-es/read-postgres`, so that a
9
+ * read model with no feed gains no `@kairos-es/feed` peer (ADR-0003). It needs
10
+ * only the GENERIC `SqlClient`, and takes no reserved connection unless the
11
+ * consumer chooses the advisory-lock lease and dedicates one to it.
12
+ *
13
+ * Where each part's argument lives:
14
+ *
15
+ * - `config.ts` and `internal/ddl.ts` — the `{ schema, tablePrefix }` pair and the
16
+ * five objects derived from it; `ensureFeedSchema.ts` — the migration payload.
17
+ * - `internal/statements.ts` — every statement the package sends, and the feed
18
+ * lock that keeps `seq` ascending in commit order.
19
+ * - `feed.ts` — the writer, the source and the wake.
20
+ * - `syncStore.ts` — the cursor, the parked list and their atomic steps.
21
+ * - `lease.ts` — the two lease kinds, and what each costs.
22
+ * - `withFeed.ts` — the reset, the generation and the binding.
23
+ *
24
+ * Wiring, end to end:
25
+ *
26
+ * ```ts
27
+ * const config = { schema: 'public', tablePrefix: 'dcb_events' }
28
+ * const key = checkpointKey(ProjectionId.make('course-search'))
29
+ *
30
+ * // 1. In YOUR migrator, beside `ensureSchema` and `ensureCheckpointSchema`:
31
+ * // ensureFeedSchema(config)
32
+ *
33
+ * // 2. Where the read model is wired, under ONE SqlClient:
34
+ * const feed = yield* makePostgresFeed({ config, feed: FeedName.make('course-search'), doc: CourseDoc })
35
+ * const store = yield* withFeed(yield* makeSqlProjectionStore(config), feed, key)
36
+ * // …and in the read model's `apply`, inside `commit`:
37
+ * // feed.write(lastPosition, (writer) => writer.upsert(FeedKey.make(id), toDoc(course)))
38
+ *
39
+ * // 3. Beside the projections, one worker per feed and sink:
40
+ * const syncStore = yield* makePostgresFeedSyncStore({ config })
41
+ * yield* Effect.forkScoped(runFeedSync({ source: feed.source, sink, store: syncStore }))
42
+ * ```
43
+ */
44
+ export { decodeFeedPostgresConfig, FeedPostgresConfig } from "./config.js";
45
+ export { ensureFeedSchema } from "./ensureFeedSchema.js";
46
+ export { eventStoreWake, makePostgresFeed } from "./feed.js";
47
+ export { advisoryLock, DEFAULT_LEASE_HEARTBEAT, DEFAULT_LEASE_TTL, leaseRow, pooledConnectionFault } from "./lease.js";
48
+ export { makePostgresFeedSyncStore } from "./syncStore.js";
49
+ export { withFeed } from "./withFeed.js";
50
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["decodeFeedPostgresConfig","FeedPostgresConfig","ensureFeedSchema","eventStoreWake","makePostgresFeed","advisoryLock","DEFAULT_LEASE_HEARTBEAT","DEFAULT_LEASE_TTL","leaseRow","pooledConnectionFault","makePostgresFeedSyncStore","withFeed"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2CA,SACEA,wBAAwB,EACxBC,kBAAkB,QACb,aAAU;AACjB,SAASC,gBAAgB,QAAQ,uBAAoB;AACrD,SACEC,cAAc,EACdC,gBAAgB,QAGX,WAAQ;AACf,SACEC,YAAY,EACZC,uBAAuB,EACvBC,iBAAiB,EAEjBC,QAAQ,EACRC,qBAAqB,QAChB,YAAS;AAChB,SACEC,yBAAyB,QAEpB,gBAAa;AACpB,SAASC,QAAQ,QAAQ,eAAY","ignoreList":[]}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What every part of this package shares about one config: the resolved names,
3
+ * the compiled statements, and the error mapping from `SqlError` to the feed's own
4
+ * two infrastructure errors.
5
+ *
6
+ * A `PostgresFeed` carries its context privately, in a `WeakMap` keyed by the
7
+ * feed value, so `withFeed` can reach the feed's names and statements without the
8
+ * public type growing a field no consumer should touch.
9
+ */
10
+ import { FeedUnreadable, FeedWriteFailed } from '@kairos-es/feed';
11
+ import { decodeFeedPostgresConfig } from "../config.js";
12
+ import { resolveFeedNames } from "./ddl.js";
13
+ import { compileFeedStatements } from "./statements.js";
14
+ /** Decode `config` and compile its statements; throws on a malformed config. */
15
+ export const feedContext = config => {
16
+ const names = resolveFeedNames(decodeFeedPostgresConfig(config));
17
+ return {
18
+ names,
19
+ statements: compileFeedStatements(names)
20
+ };
21
+ };
22
+ /** A read that failed, named for what was being read. */
23
+ export const unreadable = attempt => cause => new FeedUnreadable({
24
+ reason: `${attempt} failed: ${cause.message}`,
25
+ cause
26
+ });
27
+ /** A write that failed, named for what was being written. */
28
+ export const writeFailed = attempt => cause => new FeedWriteFailed({
29
+ reason: `${attempt} failed: ${cause.message}`,
30
+ cause
31
+ });
32
+ /** A 64-bit column read back as text, as a `bigint`; see `statements.ts`. */
33
+ export const bigintOf = text => BigInt(text);
34
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","names":["FeedUnreadable","FeedWriteFailed","decodeFeedPostgresConfig","resolveFeedNames","compileFeedStatements","feedContext","config","names","statements","unreadable","attempt","cause","reason","message","writeFailed","bigintOf","text","BigInt"],"sources":["../../../src/internal/context.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;AASA,SAASA,cAAc,EAAEC,eAAe,QAAQ,iBAAiB;AAEjE,SAASC,wBAAwB,QAAiC,cAAW;AAC7E,SAAiCC,gBAAgB,QAAQ,UAAO;AAChE,SAASC,qBAAqB,QAA6B,iBAAc;AAQzE;AACA,OAAO,MAAMC,WAAW,GAAIC,MAA0B,IAAiB;EACrE,MAAMC,KAAK,GAAGJ,gBAAgB,CAACD,wBAAwB,CAACI,MAAM,CAAC,CAAC;EAChE,OAAO;IAAEC,KAAK;IAAEC,UAAU,EAAEJ,qBAAqB,CAACG,KAAK;EAAC,CAAE;AAC5D,CAAC;AAED;AACA,OAAO,MAAME,UAAU,GACpBC,OAAe,IACfC,KAAwB,IACvB,IAAIX,cAAc,CAAC;EAAEY,MAAM,EAAE,GAAGF,OAAO,YAAYC,KAAK,CAACE,OAAO,EAAE;EAAEF;AAAK,CAAE,CAAC;AAEhF;AACA,OAAO,MAAMG,WAAW,GACrBJ,OAAe,IACfC,KAAwB,IACvB,IAAIV,eAAe,CAAC;EAClBW,MAAM,EAAE,GAAGF,OAAO,YAAYC,KAAK,CAACE,OAAO,EAAE;EAC7CF;CACD,CAAC;AAEN;AACA,OAAO,MAAMI,QAAQ,GAAIC,IAA8B,IAAaC,MAAM,CAACD,IAAI,CAAC","ignoreList":[]}