@voltro/sql-mysql 0.1.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.
@@ -0,0 +1,373 @@
1
+ import { CdcConfig } from '@voltro/database';
2
+ import { ChangeEvent } from '@voltro/database';
3
+ import { ChangeStrategy } from '@voltro/database';
4
+ import { ConfigError } from 'effect';
5
+ import { ConnectionConfig } from '@voltro/database';
6
+ import { Context } from 'effect';
7
+ import { DataStore } from '@voltro/database';
8
+ import { DialectId } from '@voltro/database';
9
+ import { DialectReplicationAdapter } from '@voltro/database';
10
+ import { Effect } from 'effect';
11
+ import { EventEmitter } from 'node:events';
12
+ import { Layer } from 'effect';
13
+ import { ManagedRuntime } from 'effect';
14
+ import { MysqlClient } from '@effect/sql-mysql2';
15
+ import { Predicate } from '@voltro/database';
16
+ import { QueryDescriptor } from '@voltro/database';
17
+ import { QueryNamespace } from '@voltro/database';
18
+ import { RawSqlFragment } from '@voltro/database/sql';
19
+ import { RetryDecision } from '@voltro/database';
20
+ import { Row } from '@voltro/database';
21
+ import { SqlClient } from '@effect/sql';
22
+ import { SqlClient as SqlClient_2 } from '@effect/sql/SqlClient';
23
+ import { SqlDialect } from '@voltro/database';
24
+ import { SqlError } from '@effect/sql';
25
+ import { TableLike } from '@voltro/database';
26
+ import { TransactionConnection } from '@effect/sql/SqlClient';
27
+
28
+ export declare interface BinlogCdcHandle {
29
+ readonly stop: () => Promise<void>;
30
+ /** Last position the reader has advanced past (for a final checkpoint). */
31
+ readonly currentPosition: () => BinlogPosition | null;
32
+ }
33
+
34
+ export declare interface BinlogCdcOptions {
35
+ readonly connection: BinlogConnection;
36
+ /** Unique per reader. Duplicate server_id silently breaks binlog streams. */
37
+ readonly serverId: number;
38
+ readonly variant: 'mysql' | 'mariadb';
39
+ /** Unqualified table names to surface; undefined = every table in `database`. */
40
+ readonly includeTables?: ReadonlyArray<string>;
41
+ /** Resume point. Omit / null → start at the current end of the binlog. */
42
+ readonly startPosition?: BinlogPosition | null;
43
+ readonly onChange: (event: ChangeEvent) => void;
44
+ /** Called with the next position after each handled event (for checkpointing). */
45
+ readonly onPosition?: (pos: BinlogPosition) => void;
46
+ readonly onError?: (err: unknown) => void;
47
+ /**
48
+ * Re-resolve the current binlog end. Used when the persisted position
49
+ * is no longer valid (binlog purged past it — err 1236 — or after a
50
+ * failover to a different primary). Provided by the store
51
+ * (SHOW MASTER STATUS).
52
+ */
53
+ readonly resolveStartPosition?: () => Promise<BinlogPosition | null>;
54
+ /**
55
+ * Called when the reader had to skip forward to the current binlog end
56
+ * (a gap: purge/failover). The caller should treat live state as
57
+ * possibly-stale and re-query (self-heal) — the dispatcher's
58
+ * per-subscribe re-query already does this on the next change.
59
+ */
60
+ readonly onResync?: () => void;
61
+ /**
62
+ * Watchdog liveness cadence + stall threshold (ms). The reader tracks its
63
+ * last progress (any binlog event or a fresh attach); when nothing has
64
+ * arrived for `stallThresholdMs`, the watchdog probes the primary's current
65
+ * binlog end (`resolveStartPosition`) and — if the primary has advanced past
66
+ * the reader — force-reconnects. This is the SAFETY NET for a silently-dead
67
+ * stream: a server-side idle timeout / half-open socket can end the binlog
68
+ * connection WITHOUT mysql2 emitting an `error`, so `zongji` never fires and
69
+ * the plain error-driven reconnect never runs. The watchdog does not depend
70
+ * on any stream event. Defaults: probe every 15s, stall after 25s.
71
+ */
72
+ readonly watchdogIntervalMs?: number;
73
+ readonly stallThresholdMs?: number;
74
+ /**
75
+ * TCP keepalive idle delay (ms) for the replication socket — the PROACTIVE
76
+ * defense against the stall the watchdog only recovers from. A binlog dump
77
+ * connection carries NO traffic during quiet periods (zongji requests no
78
+ * replication heartbeat), so an idle NAT / firewall / load-balancer silently
79
+ * reaps it and a half-open socket (e.g. the host slept) is never noticed.
80
+ * Enabling keepalive with a SHORT idle delay makes the OS emit a probe after
81
+ * this many ms of silence: the socket is never truly idle (intermediaries
82
+ * keep it), and a dead peer surfaces as a connection error in seconds rather
83
+ * than sitting frozen forever. mysql2 defaults keepalive ON but with a 0
84
+ * delay (→ the OS default idle of ~2h), which is useless here. Default 10s.
85
+ */
86
+ readonly keepAliveInitialDelayMs?: number;
87
+ }
88
+
89
+ export declare interface BinlogConnection {
90
+ readonly host: string;
91
+ readonly port: number;
92
+ readonly username: string;
93
+ readonly password: string;
94
+ readonly database: string;
95
+ }
96
+
97
+ export declare interface BinlogPosition {
98
+ readonly filename: string;
99
+ readonly position: number;
100
+ }
101
+
102
+ export declare const CDC_OFFSETS_TABLE = "_voltro_cdc_offsets";
103
+
104
+ declare class CdcDeliveryGate {
105
+ private readonly variant;
106
+ private readonly ttlMs;
107
+ private readonly schedule;
108
+ /** fingerprint → count of occurrences seen but not yet paired. A value of N
109
+ * means N deliveries have happened and are each awaiting their duplicate. */
110
+ private readonly seen;
111
+ constructor(variant: 'mysql' | 'mariadb', ttlMs?: number, schedule?: DelayScheduler);
112
+ /** Fingerprint an event by (table, op, pk, decoded-row-image). Returns null
113
+ * when there's no usable primary key (nothing to pair on → always admit). */
114
+ private key;
115
+ /**
116
+ * Decide whether to deliver this candidate. Returns true for the FIRST
117
+ * occurrence of a fingerprint (deliver it) and false for the paired second
118
+ * occurrence (the same write arriving over the other path — drop it).
119
+ */
120
+ admit(event: ChangeEvent): boolean;
121
+ private expireOne;
122
+ /** Distinct fingerprints currently awaiting their pair — tests / diagnostics. */
123
+ get pending(): number;
124
+ }
125
+
126
+ /**
127
+ * Parse a `ConnectionConfig` into the mysql-specific shape. The same
128
+ * resolver handles `mariadb://` URLs — the wire protocol is identical,
129
+ * and the dialect's `id` field is what discriminates downstream.
130
+ *
131
+ * Defaults: port 3306, user `app`, password `app`, database `app`.
132
+ */
133
+ export declare const connectionFromConfig: (config: ConnectionConfig) => MysqlConnection;
134
+
135
+ /** Injectable scheduler so tests can drive the TTL deterministically without
136
+ * real timers. The default unref's the timer so it never keeps the process
137
+ * alive. */
138
+ declare type DelayScheduler = (fn: () => void, ms: number) => void;
139
+
140
+ export declare const makeMysqlSqlLayer: (options: MysqlConnection) => ReturnType<typeof MysqlClient.layerConfig>;
141
+
142
+ export declare const makeMysqlSqlLayerFromConfig: (config: ConnectionConfig) => ReturnType<typeof MysqlClient.layerConfig>;
143
+
144
+ export declare const makeMysqlStore: (options: MysqlStoreOptions) => Promise<MysqlStore>;
145
+
146
+ /**
147
+ * MariaDB 10.6+. Same driver, separate dialect id so logs / boot
148
+ * banners / docs can say "mariadb" when that's what's live. MariaDB
149
+ * quirks (SEQUENCE-vs-AUTO_INCREMENT, JSON_VALID strictness) get
150
+ * handled inside the store via `variant === 'mariadb'` guards as they
151
+ * surface.
152
+ */
153
+ export declare const mariadbDialect: SqlDialect;
154
+
155
+ declare interface MysqlAdapterFriend {
156
+ readonly runEffect: <A, E>(effect: Effect.Effect<A, E, SqlClient_2>) => Promise<A>;
157
+ readonly variant: 'mysql' | 'mariadb';
158
+ }
159
+
160
+ export { MysqlClient }
161
+
162
+ export declare interface MysqlConnection {
163
+ readonly host: string;
164
+ readonly port: number;
165
+ readonly username: string;
166
+ readonly password: string;
167
+ readonly database: string;
168
+ readonly maxConnections?: number;
169
+ }
170
+
171
+ /**
172
+ * MySQL 8+. MySQL has no `INSERT ... RETURNING` in any version
173
+ * (that's MariaDB-only in this package) — the store produces
174
+ * post-image rows via a write-then-SELECT fallback on every mutation.
175
+ */
176
+ export declare const mysqlDialect: SqlDialect;
177
+
178
+ export declare const mysqlReplicationAdapter: () => DialectReplicationAdapter;
179
+
180
+ export declare const mysqlRetryFilter: (err: unknown) => RetryDecision;
181
+
182
+ export declare class MysqlStore implements DataStore {
183
+ private readonly sql;
184
+ private readonly runtime;
185
+ private readonly variant;
186
+ private readonly changeStrategy;
187
+ private readonly namespace;
188
+ private readonly emitter;
189
+ private inflightTxns;
190
+ private readonly log;
191
+ private cdcHandle;
192
+ private cdcCheckpointTimer;
193
+ private cdcPendingPosition;
194
+ private cdcReplicaId;
195
+ private cdcStreamName;
196
+ private readonly cdcGate;
197
+ constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | MysqlClient.MysqlClient, never>, variant: 'mysql' | 'mariadb', changeStrategy?: ChangeStrategy, namespace?: QueryNamespace, sharedEmitter?: EventEmitter, sharedGate?: CdcDeliveryGate);
198
+ /**
199
+ * Bind a per-request tenant namespace (a DATABASE on mysql/mariadb),
200
+ * returning a view that qualifies every table reference to
201
+ * `<database>.<table>`. Shares this store's pool + emitter + CDC. The
202
+ * database must already exist — see the migrator's namespace fan-out.
203
+ */
204
+ withNamespace(namespace: string | null): DataStore;
205
+ /** Qualify a table to this view's namespace (no-op when unset). */
206
+ private nsT;
207
+ /* Excluded from this release type: dialectId */
208
+ /**
209
+ * Friend-handle the mysql/mariadb replication adapter reads via a
210
+ * structural check on `DataStore` (declared in
211
+ * `./replicationAdapter.ts`). The adapter needs raw SQL access against
212
+ * THIS store's managed runtime to issue `@@global.gtid_executed` /
213
+ * `@@global.gtid_current_pos` — without a friend handle it would need
214
+ * its own connection pool + config, which would double the deployment
215
+ * cost.
216
+ *
217
+ * The handle stays on the store class as a public-but-prefixed
218
+ * property so the type cast in `adapterFriend(store)` resolves
219
+ * without exporting the runtime to the wider API surface. The
220
+ * `variant` field flows through so the adapter picks the right
221
+ * dialect-specific SQL (mysql vs mariadb session var name).
222
+ */
223
+ get __mysqlReplicationFriend(): MysqlAdapterFriend;
224
+ private executeQuery;
225
+ /**
226
+ * `RETURNING` clause support across the mysql family is NOT uniform:
227
+ *
228
+ * - MySQL (every version, including 8.x): NO `RETURNING` at all.
229
+ * - MariaDB 10.5+: `INSERT ... RETURNING *` ✓
230
+ * - MariaDB 10.0+: `DELETE ... RETURNING *` ✓
231
+ * - MariaDB (any version): `UPDATE ... RETURNING *` ✗ — explicitly
232
+ * not supported, despite the otherwise
233
+ * broad RETURNING coverage.
234
+ *
235
+ * Two per-operation flags keep the dispatch explicit. The fallback
236
+ * paths INSERT/UPDATE/DELETE-then-SELECT-by-known-id all rely on
237
+ * the framework's auto-inject middleware which generates ids
238
+ * client-side (TypeID / ULID / Snowflake), so we always know the
239
+ * row's primary key BEFORE issuing the mutation. Getters (not
240
+ * inline `=` initializers) avoid referring to `this.variant`
241
+ * before constructor assignment.
242
+ */
243
+ private get supportsInsertReturning();
244
+ private get supportsDeleteReturning();
245
+ private get supportsUpdateReturning();
246
+ private executeInsert;
247
+ /**
248
+ * AUTO_INCREMENT id recovery for a single INSERT with no client-side
249
+ * id. `LAST_INSERT_ID()` is connection-scoped, so INSERT →
250
+ * LAST_INSERT_ID() → post-image SELECT must share one pinned
251
+ * connection. When the caller already holds a txn connection we run
252
+ * on it; otherwise we open a short transaction to pin the pool
253
+ * connection for the round-trip. Retries on a deadlock/lock-timeout.
254
+ */
255
+ private insertRecoverAutoId;
256
+ /**
257
+ * Run `body` on ONE pinned connection. Inside a caller-held txn we run
258
+ * on that connection directly; otherwise we open a short transaction so
259
+ * the connection-scoped `LAST_INSERT_ID()` stays valid across the
260
+ * INSERT → read → post-SELECT round-trip. The transactional path retries
261
+ * on a deadlock / lock-wait timeout (mirrors `updateMany`/`deleteMany`).
262
+ */
263
+ private runPinned;
264
+ private executeInsertMany;
265
+ /**
266
+ * AUTO_INCREMENT id recovery for a multi-row INSERT with no client-side
267
+ * ids. `LAST_INSERT_ID()` returns the FIRST generated id; the engine
268
+ * allocates the rest consecutively (guaranteed for a single multi-row
269
+ * `INSERT … VALUES` under the default `innodb_autoinc_lock_mode`), so
270
+ * the range is [first, first + n - 1]. Re-selects that range ordered by
271
+ * id (= input order) for the post-images. Same connection-pinning +
272
+ * retry as the single-row path.
273
+ */
274
+ private insertManyRecoverAutoIds;
275
+ private executePatchJson;
276
+ private executeUpdate;
277
+ private executeDelete;
278
+ private routeEvent;
279
+ private executeUpsert;
280
+ /**
281
+ * MariaDB 10.5+ native upsert:
282
+ * `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col), … RETURNING *`.
283
+ * One round-trip. `RETURNING *` gives the final row (post-insert OR
284
+ * post-update). The conflict columns + id are excluded from the SET
285
+ * list (they identify the row and must not be silently rewritten),
286
+ * matching the postgres `ON CONFLICT … DO UPDATE` shape.
287
+ */
288
+ private executeMariadbUpsert;
289
+ private executeInsertIgnore;
290
+ private findByConflict;
291
+ query(d: QueryDescriptor): Promise<readonly Readonly<Record<string, unknown>>[]>;
292
+ raw<T extends object = Row>(fragment: RawSqlFragment, _opts?: {
293
+ dependsOn?: ReadonlyArray<string>;
294
+ }): Promise<ReadonlyArray<T>>;
295
+ private runWithEager;
296
+ /* Excluded from this release type: getInternalRunWithEager */
297
+ insert(t: string, r: Row): Promise<Readonly<Record<string, unknown>>>;
298
+ insertMany(t: string, rows: ReadonlyArray<Row>): Promise<readonly Readonly<Record<string, unknown>>[]>;
299
+ patchJson(t: string, pk: string, path: string, value: unknown): Promise<Readonly<Record<string, unknown>> | null>;
300
+ upsert(t: string, r: Row, options: {
301
+ conflictColumns: ReadonlyArray<string>;
302
+ update?: ReadonlyArray<string> | ((existing: Row) => Readonly<Record<string, unknown>>);
303
+ }): Promise<Readonly<Record<string, unknown>>>;
304
+ insertIgnore(t: string, r: Row, options: {
305
+ conflictColumns: ReadonlyArray<string>;
306
+ }): Promise<Readonly<Record<string, unknown>>>;
307
+ update(t: string, pk: string, p: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>> | null>;
308
+ delete(t: string, pk: string): Promise<boolean>;
309
+ updateMany(table: string, patch: Readonly<Record<string, unknown>>, options: {
310
+ where: Predicate;
311
+ }): Promise<number>;
312
+ deleteMany(table: string, options: {
313
+ where: Predicate;
314
+ }): Promise<number>;
315
+ /* Excluded from this release type: emitChange */
316
+ /* Excluded from this release type: startCdcConsumer */
317
+ /* Excluded from this release type: assertBinlogConfig */
318
+ /* Excluded from this release type: resolveBinlogEnd */
319
+ /* Excluded from this release type: readCdcOffset */
320
+ /* Excluded from this release type: flushCdcOffset */
321
+ /* Excluded from this release type: getInternalExecuteQuery */
322
+ /* Excluded from this release type: getInternalExecuteInsert */
323
+ /* Excluded from this release type: getInternalExecuteInsertMany */
324
+ /* Excluded from this release type: getInternalExecutePatchJson */
325
+ /* Excluded from this release type: getInternalExecuteUpdate */
326
+ /* Excluded from this release type: getInternalExecuteDelete */
327
+ /* Excluded from this release type: getInternalExecuteUpsert */
328
+ /* Excluded from this release type: getInternalExecuteInsertIgnore */
329
+ transactional<T>(work: (tx: DataStore) => Promise<T>): Promise<T>;
330
+ onChange(listener: (event: ChangeEvent) => void): () => void;
331
+ /** Cross-instance reactivity seam — emit an externally-sourced event to
332
+ * local subscribers without re-persisting. The binlog CDC consumer and
333
+ * `@voltro/plugin-broadcast` route through here. See
334
+ * `DataStore.injectExternalChange`. */
335
+ /** 'fleet' under binlog CDC (every replica gets the full stream);
336
+ * 'local' under inline emission. See DataStore.changeScope. */
337
+ get changeScope(): 'local' | 'fleet';
338
+ injectExternalChange(event: ChangeEvent): void;
339
+ run<A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>): Promise<A>;
340
+ close(gracePeriodMs?: number): Promise<void>;
341
+ /** Liveness probe — `SELECT 1`. Rejects if the pool can't answer. */
342
+ ping(): Promise<void>;
343
+ }
344
+
345
+ export declare interface MysqlStoreOptions {
346
+ readonly sqlLayer: Layer.Layer<SqlClient.SqlClient | MysqlClient.MysqlClient, ConfigError.ConfigError | SqlError.SqlError, never>;
347
+ readonly tracerLayer?: Layer.Layer<never, never, never>;
348
+ /** `'inline'` (default): local reactivity only — the writing instance sees
349
+ * its own deltas. `'cdc'`: ADDITIVE cross-instance fan-out — the writer
350
+ * still emits its own deltas inline, and a ROW-format binlog reader also
351
+ * surfaces every OTHER replica's writes on this replica's `onChange` (own
352
+ * echoes deduped). Supported on BOTH mysql-8 and mariadb (same binlog
353
+ * reader, per-variant detection). */
354
+ readonly changeStrategy?: ChangeStrategy;
355
+ /** Binlog CDC wiring — required when `changeStrategy: 'cdc'` (mysql or mariadb). */
356
+ readonly cdcConfig?: CdcConfig;
357
+ /** Variant id — `'mysql'` or `'mariadb'`. Surfaces in spans + logs;
358
+ * the wire protocol is identical so no behavioural branch needed. */
359
+ readonly variant?: 'mysql' | 'mariadb';
360
+ }
361
+
362
+ /**
363
+ * Start a binlog reader. Resolves once the reader is attached and
364
+ * streaming (so the caller can guarantee "reader attached before
365
+ * subscriptions"). The returned handle stops it.
366
+ */
367
+ export declare const startBinlogCdc: (opts: BinlogCdcOptions) => Promise<BinlogCdcHandle>;
368
+
369
+ declare type TxnContext = Context.Tag.Service<typeof TransactionConnection>;
370
+
371
+ export declare const _voltroCdcOffsetsTable: TableLike;
372
+
373
+ export { }