@kairos-es/feed-postgres 0.0.0-stage → 1.0.0-alpha.6

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,37 @@
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
+ /**
21
+ * Configuration for the feed's tables.
22
+ *
23
+ * - `schema` — the Postgres schema that owns them (default `public`).
24
+ * - `tablePrefix` — the stem every name derives from (default `dcb_events`,
25
+ * giving `dcb_events_feed_rows` and its siblings).
26
+ */
27
+ export declare const FeedPostgresConfig: Schema.Struct<{
28
+ readonly schema: Schema.optionalKey<Schema.String>;
29
+ readonly tablePrefix: Schema.optionalKey<Schema.String>;
30
+ }>;
31
+ export type FeedPostgresConfig = typeof FeedPostgresConfig.Type;
32
+ /** Decode a config, throwing a `SchemaError` on any violation. */
33
+ export declare const decodeFeedPostgresConfig: (input: unknown, options?: import("effect/SchemaAST").ParseOptions) => {
34
+ readonly schema?: string;
35
+ readonly tablePrefix?: string;
36
+ };
37
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAY/B;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB;;;EAGqC,CAAA;AACpE,MAAM,MAAM,kBAAkB,GAAG,OAAO,kBAAkB,CAAC,IAAI,CAAA;AAE/D,kEAAkE;AAClE,eAAO,MAAM,wBAAwB;;;CACS,CAAA"}
@@ -0,0 +1,24 @@
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, type SqlError } from 'effect/sql';
21
+ import { type FeedPostgresConfig } from './config.js';
22
+ /** The feed tables' migration payload for `config`. */
23
+ export declare const ensureFeedSchema: (config?: FeedPostgresConfig) => Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
24
+ //# sourceMappingURL=ensureFeedSchema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensureFeedSchema.d.ts","sourceRoot":"","sources":["../../src/ensureFeedSchema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrD,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAG5E,uDAAuD;AACvD,eAAO,MAAM,gBAAgB,GAC3B,SAAQ,kBAAuB,KAC9B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC,SAAS,CAKzD,CAAA"}
@@ -0,0 +1,81 @@
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, type Query } from '@kairos-es/core';
49
+ import { type BatchedFeedWriter, type FeedName, type FeedSource, type FeedUnreadable, type FeedWriteFailed } from '@kairos-es/feed';
50
+ import { Effect, Schema, Stream } from 'effect';
51
+ import { SqlClient } from 'effect/sql';
52
+ import type { FeedPostgresConfig } from './config.js';
53
+ /** A Postgres feed: its writer, and the source a sync worker reads. */
54
+ export interface PostgresFeed<D> extends BatchedFeedWriter<D, SqlClient.SqlClient> {
55
+ readonly source: FeedSource<D>;
56
+ }
57
+ /** Options for {@link makePostgresFeed}. */
58
+ export interface PostgresFeedOptions<D, I> {
59
+ /** The `{ schema, tablePrefix }` pair; the same one the event store and read side take. */
60
+ readonly config?: FeedPostgresConfig;
61
+ readonly feed: FeedName;
62
+ /** The document's schema: what the writer encodes through and the source decodes through. */
63
+ readonly doc: Schema.Codec<D, I>;
64
+ /** A wake for the sync worker, such as {@link eventStoreWake}'s. The poll is the guarantee. */
65
+ readonly listen?: Stream.Stream<unknown, FeedUnreadable>;
66
+ }
67
+ /**
68
+ * Build a feed over the ambient `SqlClient`. Creates the feed's generations row
69
+ * if it is absent, at generation 0, so the writer's feed lock always has a row to
70
+ * take; that is the only write it makes. `ensureFeedSchema` must have run first.
71
+ */
72
+ export declare const makePostgresFeed: <D, I>(options: PostgresFeedOptions<D, I>) => Effect.Effect<PostgresFeed<D>, FeedWriteFailed, SqlClient.SqlClient>;
73
+ /**
74
+ * A wake from the event store: one element per event matching `query` appended
75
+ * after the moment the stream is subscribed, and nothing else. Pass it as
76
+ * `listen`. Early by the projection's own lag, as the module doc says, so the
77
+ * worker's poll still bounds the latency; `query` should be the read model's own,
78
+ * so events it never applies wake nothing.
79
+ */
80
+ export declare const eventStoreWake: (query: Query) => Effect.Effect<Stream.Stream<unknown>, never, DcbEventStore>;
81
+ //# sourceMappingURL=feed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/feed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,OAAO,EACL,aAAa,EAEb,KAAK,KAAK,EAEX,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,KAAK,iBAAiB,EAEtB,KAAK,QAAQ,EAGb,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,eAAe,EAGrB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,MAAM,EAAU,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AASlD,uEAAuE;AACvE,MAAM,WAAW,YAAY,CAAC,CAAC,CAC7B,SAAQ,iBAAiB,CAAC,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC;IACjD,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;CAC/B;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB,CAAC,CAAC,EAAE,CAAC;IACvC,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAA;IACpC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,6FAA6F;IAC7F,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChC,+FAA+F;IAC/F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,cAAc,CAAC,CAAA;CACzD;AAiBD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,CAAC,EACnC,SAAS,mBAAmB,CAAC,CAAC,EAAE,CAAC,CAAC,KACjC,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,SAAS,CAAC,SAAS,CAoJlE,CAAA;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,KAAK,KACX,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,aAAa,CASzD,CAAA"}
@@ -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, type PostgresFeed, type PostgresFeedOptions, } from './feed.js';
47
+ export { advisoryLock, DEFAULT_LEASE_HEARTBEAT, DEFAULT_LEASE_TTL, type FeedLeaseConfig, leaseRow, pooledConnectionFault, } from './lease.js';
48
+ export { makePostgresFeedSyncStore, type PostgresFeedSyncStoreOptions, } from './syncStore.js';
49
+ export { withFeed } from './withFeed.js';
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,OAAO,EACL,wBAAwB,EACxB,kBAAkB,GACnB,MAAM,UAAU,CAAA;AACjB,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,GACzB,MAAM,QAAQ,CAAA;AACf,OAAO,EACL,YAAY,EACZ,uBAAuB,EACvB,iBAAiB,EACjB,KAAK,eAAe,EACpB,QAAQ,EACR,qBAAqB,GACtB,MAAM,SAAS,CAAA;AAChB,OAAO,EACL,yBAAyB,EACzB,KAAK,4BAA4B,GAClC,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA"}
@@ -0,0 +1,28 @@
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 type { SqlError } from 'effect/sql';
12
+ import { type FeedPostgresConfig } from '../config.js';
13
+ import { type ResolvedFeedNames } from './ddl.js';
14
+ import { type FeedStatements } from './statements.js';
15
+ /** One config's names and statements. */
16
+ export interface FeedContext {
17
+ readonly names: ResolvedFeedNames;
18
+ readonly statements: FeedStatements;
19
+ }
20
+ /** Decode `config` and compile its statements; throws on a malformed config. */
21
+ export declare const feedContext: (config: FeedPostgresConfig) => FeedContext;
22
+ /** A read that failed, named for what was being read. */
23
+ export declare const unreadable: (attempt: string) => (cause: SqlError.SqlError) => FeedUnreadable;
24
+ /** A write that failed, named for what was being written. */
25
+ export declare const writeFailed: (attempt: string) => (cause: SqlError.SqlError) => FeedWriteFailed;
26
+ /** A 64-bit column read back as text, as a `bigint`; see `statements.ts`. */
27
+ export declare const bigintOf: (text: string | bigint | number) => bigint;
28
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../src/internal/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACjE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAC1C,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAC7E,OAAO,EAAE,KAAK,iBAAiB,EAAoB,MAAM,OAAO,CAAA;AAChE,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAEzE,yCAAyC;AACzC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAA;IACjC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;CACpC;AAED,gFAAgF;AAChF,eAAO,MAAM,WAAW,GAAI,QAAQ,kBAAkB,KAAG,WAGxD,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,UAAU,GACpB,SAAS,MAAM,MACf,OAAO,QAAQ,CAAC,QAAQ,KAAG,cACkD,CAAA;AAEhF,6DAA6D;AAC7D,eAAO,MAAM,WAAW,GACrB,SAAS,MAAM,MACf,OAAO,QAAQ,CAAC,QAAQ,KAAG,eAIxB,CAAA;AAEN,6EAA6E;AAC7E,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,GAAG,MAAM,GAAG,MAAM,KAAG,MAAsB,CAAA"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Internal object naming and DDL for the Postgres change feed.
3
+ *
4
+ * The single source of truth for the FIVE objects this package owns — the feed
5
+ * rows, the sequence they share, the generations, the sync cursors and the parked
6
+ * list — and for the idempotent DDL that creates them. Every name is derived ONCE
7
+ * from `{ schema, tablePrefix }` and reused by `ensureFeedSchema` (the migration
8
+ * payload) and by every runtime statement in `statements.ts`, so a migration and a
9
+ * query can never disagree about what an object is called.
10
+ *
11
+ * The pair is the SAME one the event store and `@kairos-es/read-postgres` take, so
12
+ * the feed's tables land beside the log and the checkpoints they belong with, and
13
+ * one prefix keeps naming one complete, isolated world (ADR-0002).
14
+ *
15
+ * The identifier discipline is `@kairos-es/read-postgres`'s, duplicated rather
16
+ * than imported for the reason that package gives for duplicating the event
17
+ * store's: each package measures and quotes only the names IT materialises, and
18
+ * importing a sibling backend's internals would couple two packages that must
19
+ * stay independently publishable. Identifiers are pre-quoted here and embedded as
20
+ * literal text into `sql.unsafe`; values are always bound `$n` parameters. Names
21
+ * never pass through `sql(identifier)`, because the consumer's client may carry a
22
+ * query-name transform that would rewrite them.
23
+ */
24
+ import type { SqlClient, Statement } from 'effect/sql';
25
+ /** Every object a feed config owns, as qualified (unquoted) names. */
26
+ export interface ResolvedFeedNames {
27
+ readonly schema: string;
28
+ readonly tablePrefix: string;
29
+ /** `<schema>.<prefix>_feed_rows`: one row per feed and key, the newest state. */
30
+ readonly rowsTable: string;
31
+ /** `<prefix>_feed_rows_seq_idx`: the `(feed, seq)` index the tail reads by. */
32
+ readonly rowsSeqIndex: string;
33
+ /** `<schema>.<prefix>_feed_seq`: the one sequence every feed in the prefix shares. */
34
+ readonly sequence: string;
35
+ /** `<schema>.<prefix>_feed_generations`: each feed's generation and binding. */
36
+ readonly generationsTable: string;
37
+ /** `<schema>.<prefix>_feed_cursors`: one cursor and lease per feed and sink. */
38
+ readonly cursorsTable: string;
39
+ /** `<schema>.<prefix>_feed_parked`: the records a sink refused. */
40
+ readonly parkedTable: string;
41
+ }
42
+ /** The default schema, identical to the event store's and the read side's. */
43
+ export declare const DEFAULT_SCHEMA = "public";
44
+ /** The default table prefix, identical to the event store's and the read side's. */
45
+ export declare const DEFAULT_TABLE_PREFIX = "dcb_events";
46
+ /**
47
+ * The first derived identifier over Postgres's 63-byte limit, as a message, or
48
+ * `undefined` when every name fits. Postgres truncates an over-long identifier
49
+ * with only a notice, so two prefixes sharing a 63-byte stem would silently share
50
+ * one feed table; refusing at the boundary beats that.
51
+ */
52
+ export declare const oversizedIdentifier: (config: {
53
+ readonly schema?: string;
54
+ readonly tablePrefix?: string;
55
+ }) => string | undefined;
56
+ /** Derive every name from the pair; throws on an over-long identifier. */
57
+ export declare const resolveFeedNames: (config: {
58
+ readonly schema?: string;
59
+ readonly tablePrefix?: string;
60
+ }) => ResolvedFeedNames;
61
+ /** Quote a possibly qualified identifier, atom by atom, for literal SQL text. */
62
+ export declare const quoteQualified: (name: string) => string;
63
+ /** Quote one identifier atom, doubling any embedded `"`. */
64
+ export declare const quoteAtom: (name: string) => string;
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 declare const sequenceLiteral: (names: ResolvedFeedNames) => string;
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 declare const feedDdlStatements: (names: ResolvedFeedNames) => ReadonlyArray<string>;
97
+ /** The DDL as statements on `sql`, one `sql.unsafe` each, in order. */
98
+ export declare const runFeedDdl: (sql: SqlClient.SqlClient, names: ResolvedFeedNames) => ReadonlyArray<Statement.Statement<unknown>>;
99
+ //# sourceMappingURL=ddl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl.d.ts","sourceRoot":"","sources":["../../../src/internal/ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAEtD,sEAAsE;AACtE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,gFAAgF;IAChF,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,gFAAgF;IAChF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,mEAAmE;IACnE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,8EAA8E;AAC9E,eAAO,MAAM,cAAc,WAAW,CAAA;AACtC,oFAAoF;AACpF,eAAO,MAAM,oBAAoB,eAAe,CAAA;AAuChD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B,KAAG,MAAM,GAAG,SAQZ,CAAA;AAED,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,GAAI,QAAQ;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B,KAAG,iBAkBH,CAAA;AAED,iFAAiF;AACjF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,MAIhC,CAAA;AAEd,4DAA4D;AAC5D,eAAO,MAAM,SAAS,GAAI,MAAM,MAAM,KAAG,MACR,CAAA;AAEjC;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,iBAAiB,KAAG,MACA,CAAA;AAE3D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,iBAAiB,KACvB,aAAa,CAAC,MAAM,CAmDtB,CAAA;AAED,uEAAuE;AACvE,eAAO,MAAM,UAAU,GACrB,KAAK,SAAS,CAAC,SAAS,EACxB,OAAO,iBAAiB,KACvB,aAAa,CAAC,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,CACuB,CAAA"}
@@ -0,0 +1,127 @@
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 { type ResolvedFeedNames } from './ddl.js';
44
+ /** Every statement text, compiled for one name set. */
45
+ export interface FeedStatements {
46
+ /** `$1` feed. Creates the generations row at generation 0 if absent. */
47
+ readonly ensureGeneration: string;
48
+ /** `$1` feed. The writer's share of the feed lock. */
49
+ readonly lockFeedShared: string;
50
+ /** `$1` feed, `$2` event position, `$3` jsonb `[{ key, doc }]`. */
51
+ readonly upsertRows: string;
52
+ /** `$1` feed, `$2` event position, `$3` jsonb `[key]`. */
53
+ readonly tombstoneRows: string;
54
+ /** `$1` feed. */
55
+ readonly selectGeneration: string;
56
+ /** `$1` feed, `$2` after seq, `$3` limit. */
57
+ readonly changesAfter: string;
58
+ /** `$1` feed, `$2` sink. */
59
+ readonly selectCursor: string;
60
+ /**
61
+ * `$1` feed, `$2` sink, `$3` holder, `$4` next generation, `$5` next seq,
62
+ * `$6` expected generation (NULL for none), `$7` expected seq (NULL for none),
63
+ * `$8` now ms, recorded as the sink's last advance.
64
+ */
65
+ readonly casCursor: string;
66
+ /** `$1` feed, `$2` sink. */
67
+ readonly clearParked: string;
68
+ /** `$1` feed, `$2` sink, `$3` jsonb `[key]`. */
69
+ readonly unparkKeys: string;
70
+ /** `$1` feed, `$2` sink, `$3` jsonb `[{ key, seq, reason, parked_at_ms }]`. */
71
+ readonly parkKeys: string;
72
+ /** `$1` feed, `$2` sink. */
73
+ readonly selectParked: string;
74
+ /** `$1` feed. The feed lock, exclusively, for `retryParked`. */
75
+ readonly lockFeedExclusive: string;
76
+ /** `$1` feed, `$2` sink, `$3` key. */
77
+ readonly unparkOne: string;
78
+ /** `$1` feed, `$2` key. */
79
+ readonly restampRow: string;
80
+ /** `$1` feed. */
81
+ readonly collectTombstones: string;
82
+ /** `$1` lock name. The advisory lock, held until the transaction ends. */
83
+ readonly tryAdvisoryXactLock: string;
84
+ /** `$1` feed, `$2` sink, `$3` now ms. Deletes the cursor row unless a live lease row holds it. */
85
+ readonly deleteCursorUnlessLeased: string;
86
+ /** `$1` feed, `$2` sink. Whether a cursor row is still there. */
87
+ readonly cursorExists: string;
88
+ /** `$1` feed, `$2` sink. Every park of the sink, one row back per park. */
89
+ readonly deleteSinkParked: string;
90
+ /** `$1` feed. */
91
+ readonly selectHead: string;
92
+ /** `$1` feed. */
93
+ readonly describeSinks: string;
94
+ /** `$1` feed. */
95
+ readonly describeParked: string;
96
+ /**
97
+ * `$1` feed, `$2` sink, `$3` holder, `$4` expiry ms, `$5` now ms, `$6` whether
98
+ * the caller holds the sink's advisory lock for its transaction.
99
+ */
100
+ readonly takeLeaseRow: string;
101
+ /** `$1` feed, `$2` sink, `$3` holder, `$4` expiry ms. */
102
+ readonly renewLeaseRow: string;
103
+ /** `$1` feed, `$2` sink, `$3` holder. */
104
+ readonly releaseLease: string;
105
+ /**
106
+ * `$1` feed, `$2` sink, `$3` holder, `$4` now ms, `$5` lock name. On the
107
+ * dedicated connection. Records an advisory holder unless a live lease row
108
+ * holds the row or this session no longer holds the lock; zero rows means one
109
+ * of the two.
110
+ */
111
+ readonly claimAdvisoryHolder: string;
112
+ /** `$1` lock name. On the dedicated connection. */
113
+ readonly tryAdvisoryLock: string;
114
+ /** `$1` lock name. On the dedicated connection. */
115
+ readonly holdsAdvisoryLock: string;
116
+ /** `$1` lock name. On the dedicated connection. */
117
+ readonly advisoryUnlock: string;
118
+ /** `$1` feed, `$2` projection, `$3` partition. */
119
+ readonly bindFeed: string;
120
+ /** `$1` feed. */
121
+ readonly bumpGeneration: string;
122
+ /** `$1` feed. */
123
+ readonly deleteFeedRows: string;
124
+ }
125
+ /** Compile every statement for `names`. */
126
+ export declare const compileFeedStatements: (names: ResolvedFeedNames) => FeedStatements;
127
+ //# sourceMappingURL=statements.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"statements.d.ts","sourceRoot":"","sources":["../../../src/internal/statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,EAAkB,KAAK,iBAAiB,EAAmB,MAAM,OAAO,CAAA;AAE/E,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAE7B,wEAAwE;IACxE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,sDAAsD;IACtD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,mEAAmE;IACnE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAE9B,iBAAiB;IACjB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAE7B,4BAA4B;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,4BAA4B;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,4BAA4B;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,gEAAgE;IAChE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,sCAAsC;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,2BAA2B;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iBAAiB;IACjB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAElC,0EAA0E;IAC1E,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAA;IACpC,kGAAkG;IAClG,QAAQ,CAAC,wBAAwB,EAAE,MAAM,CAAA;IACzC,iEAAiE;IACjE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,iBAAiB;IACjB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iBAAiB;IACjB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,iBAAiB;IACjB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAE/B;;;OAGG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,yDAAyD;IACzD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,yCAAyC;IACzC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAA;IACpC,mDAAmD;IACnD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,mDAAmD;IACnD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,mDAAmD;IACnD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAE/B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,iBAAiB;IACjB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,iBAAiB;IACjB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;CAChC;AAED,2CAA2C;AAC3C,eAAO,MAAM,qBAAqB,GAChC,OAAO,iBAAiB,KACvB,cA2PF,CAAA"}
@@ -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": "commonjs"
3
+ }