@voltro/sql-mssql 0.19.0 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,93 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.20.0] — 2026-07-29
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/plugin-versioning, @voltro/database, @voltro/voltro, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — `versioningPlugin({ timing: 'in-transaction' })` produced a WRONG trail, not merely a slow one. Reported and reproduced against MariaDB 11 by a team that wired both plugins and measured before migrating a single call site.
47
+
48
+ **It recorded every change twice.** The two timings are alternatives, but the post-commit change tap stayed wired when the in-transaction recorder was registered, so both ran. One `bookmarks.create` → two history rows.
49
+
50
+ **And the trail was mis-ordered, which is worse.** Each path numbered independently: one insert plus one update produced versions `0, 0, 1, 2` across four rows. `selectAsOf`, `sortHistory` and `diffVersionRows` all read `version`, so `rowAsOf` returned the wrong snapshot and `diffVersions` found nothing. A duplicate can be deduped; a wrong order cannot be detected from the data.
51
+
52
+ The recorder wrote a constant `version: 0` on purpose, with a design note arguing that ordering could come from `changedAt` and that a read per covered write was too expensive. Both halves were wrong: `changedAt` is millisecond-resolution, so two writes to one row inside one transaction tie routinely, and the number is what every reader consults.
53
+
54
+ **BREAKING —** a `WriteRecorder` now receives a PORT (`{ append, maxOf }`) rather than a bare `append`. `maxOf` is one aggregate with an equality filter on the connection the write already holds; it is what lets an append-only trail number its own entries. A recorder still cannot UPDATE, DELETE or open a nested transaction, and a throw from either operation still rolls the caller's write back. Apps that merely ENABLE the timing need no change — only a hand-written recorder does, and `tsc` names every site.
55
+
56
+ **Cost, stated rather than avoided:** `'in-transaction'` now takes TWO round-trips per recorded write, roughly doubling this timing's published per-write overhead. Both timings number from 1, so switching `timing` no longer shifts version numbers.
57
+
58
+ ### Fixed
59
+
60
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — The correlation bridge did not survive a transaction, and did not survive CDC. Both are fixed, and both were found by measuring against live databases after a consumer isolated the symptom in a scratch app.
61
+
62
+ **Every write a framework mutation makes was unattributed.** `transactional()` is entered from the request's async-local scope, but its callback runs from inside the Effect the store builds — and measured against live postgres AND live mariadb, the scope is active at the call site and EMPTY inside the callback. Framework mutations are auto-transactional, so this was every handler write. Same class as the `bindMutation` defect fixed alongside it: a scope covering the construction of an Effect and not its execution. The caller's attribution is now captured at `transactional()` entry and re-established around the callback, in all four dialect stores.
63
+
64
+ **And the CDC transports could not carry it at all.** Under `changeStrategy: 'cdc'` — the DEFAULT — the event a subscriber receives is rebuilt from a postgres NOTIFY payload or a mysql binlog row image, neither of which can hold a request context. `registerPendingAttribution` / `claimPendingAttribution` (`@voltro/database`) let the write path hand its identity to the echo, keyed by `(table, op, id)` and claimed once. A write made on ANOTHER replica has nothing pending and stays unattributed, which is the correct answer rather than a gap.
65
+
66
+ **Plus one nobody had reported, found on the way:** on postgres under CDC the write path skipped `routeEvent` entirely, and `runWriteRecorders` lives inside it — so `versioningPlugin({ timing: 'in-transaction' })` with the default `CDC=1` recorded NOTHING. The mode whose entire promise is "if the change committed, the entry is there" wrote an empty trail, silently. `routeEvent` now runs in both modes; only the DELIVERY decision is strategy-dependent.
67
+
68
+ New live-dialect suites (`cdcAttribution.integration.test.ts` in `sql-postgres` and `sql-mysql`) pin all of it, and were verified red against the previous code.
69
+ - **@voltro/plugin-versioning, @voltro/runtime, @voltro/cli** — `_voltro_row_history.traceId` and `.subjectId` were NULL on every write. Three independent causes, all found from one consumer report whose evidence pinned the diagnosis before we looked: `subjectId` was NULL while `changedBy` on the SAME row carried the acting user — so the identity was known and was not travelling.
70
+
71
+ - **The adapter dropped them.** `dataStoreHistoryStore.append` hand-wrote its insert object and listed `changedBy` but not `traceId` / `subjectId`. This is the second time that shape has bitten in this file — the READ side (`rowToVersion`) had drifted identically. A row built by hand in one place and read by hand in another disagree exactly when a field is ADDED, because nothing fails. Both now spread the row. - **An Effect-returning handler was unattributed.** `bindMutation` established the scope around the CALL, which covers an async executor for its whole run — but an Effect-returning one is only CONSTRUCTED there and runs later. It is now forked inside the scope, with interruption and typed failures preserved (both pinned by tests). Verified by measurement, not assumption: an Effect forked inside an ALS scope keeps seeing it across `sleep`, `yieldNow` and a `setTimeout` promise, while the same effect merely constructed inside sees nothing. - **The devtools `/invoke` path never entered the scope at all.** It bypasses the rpc stack by design, and that also bypassed everything `bindMutation` sets up. The audit plugin recorded a traceId (it reads `requestContext.traceId`, which this path does build) while every write underneath carried none — three consumers of one call disagreeing about its trace. It also synthesised `inspect-<8 random chars>`, which no trace consumer can parse; the reporter's framing is the rule worth keeping — *a synthesised id produces a column that looks joinable and is not; NULL at least fails honestly.* It is a real 32-hex id now, and it reaches all three sinks.
72
+
73
+ `actingUserId` is imported at the new call site rather than re-derived — one answer to "who is writing", shared with what `audit()` stamps.
74
+ - **@voltro/cli** — Three ways a check reported nothing while checking nothing, all found by a consumer verifying the silence instead of trusting it.
75
+
76
+ - **`unexercised-row-filter` never fired on a typed registration.** The match was `/\bsetRowFilter\s*\(/`, which demands the paren directly after the name, so `setRowFilter<Ctx>({…})` — the spelling our own generic signature invites — broke it. The rule was blind for exactly the teams that had wired `load`/`predicate` carefully. It counts CALLS now. - **…and its test-side condition was satisfiable by a COMMENT.** It matched `rowFilter:` in raw text. Comments and string literals are stripped, and the suite must both call `makeTestContext` and bind `rowFilter` in real code. - **The same paren-adjacent shape sat in two shipped codemod gates.** `0.7.0/01` (row filter) and `0.7.0/02` (`invoke`) both gate on a generic export, so a typed call made `voltro update` print nothing at all: the upgrade reads as clean and the behaviour change lands unread. Both now use the shared `callPattern`, which allows type arguments including nested ones.
77
+
78
+ Two false-positive fixes in the hand-roll detector, from the same report:
79
+
80
+ - **A file that WIRES a plugin is no longer told to adopt it.** The `presence` rule reported `app.config.ts` (which calls `presencePlugin()`) to an app that had just deleted its hand-rolled table. Rules that recommend a package now declare it, and a file referencing that package is skipped. - **Generated files and `.d.ts` are out of the scan.** A recommendation aimed at a file the next boot overwrites is never actionable.
81
+
82
+ And one more of the first kind, found while checking why a withdrawn report's probe had stayed silent: `raw-fetch` counted only a BARE `fetch(…)` callee, so `globalThis.fetch(url)` / `self.fetch(url)` in a server file read as clean.
83
+ - **@voltro/cli** — `voltro doctor`'s `serverOnly: NOT CHECKED` line now names the failure, and the field exists in `--json`.
84
+
85
+ The refusal to claim a pass was right. What shipped with it was nothing to act on: the `catch` discarded the error entirely, so there was no reason, no failing module, and — because the field was absent from `--json` — no way for CI to assert "still unchecked" rather than reading silence as a pass.
86
+
87
+ A consumer's verdict, which is the useful part: *"The message is honest and that is the problem."* They had already verified that every descriptor, `app.config.ts` and the generated rpc group imported cleanly under `tsx` on their own, so the difference had to be in what `loadDiscovered` does BEYOND importing — and none of that was visible from outside. It matters more than its size because `.serverOnly()` is what guards their `sessions.tokenHash` and `apiKeys.keyHash`, markers they added after finding a query whose output schema shipped a hash over the wire.
88
+
89
+ `--json` now carries `serverOnly: { checked, reason?, leaks? }`. Gate CI on `checked === false`.
90
+ - **@voltro/plugin-versioning** — A version snapshot no longer copies `.serverOnly()` columns into `_voltro_row_history`. `.encrypted()` columns are KEPT, and that distinction is the whole finding.
91
+
92
+ Reported by a team choosing which tables to version: `sessions` holds `.encrypted()` PATs and a `tokenHash`, `apiKeys` holds a `keyHash`, and they could not determine from outside what the snapshot would contain. They excluded both tables — then went and measured it, which corrected their own report:
93
+
94
+ ```
95
+ probeItems.secret enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP…
96
+ _voltro_row_history {"secret":"enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:…"}
97
+ ```
98
+
99
+ **`.encrypted()` lands as ciphertext, byte-identical to the source column**, so versioning such a table widens nothing — the history is exactly as readable as the row it came from. Withholding it would have cost real audit data to prevent an exposure that does not exist.
100
+
101
+ **`.serverOnly()` is withheld**, and the reason is not "a second copy under different retention" — that argument is weak on its own, since the hash already sits in the source table. The decisive one: `crud.*` STRIPS `.serverOnly()` columns from every row it returns, and a snapshot would smuggle the same value back past that stripping inside a `json()` blob, where no column-level rule applies.
102
+
103
+ Withheld names are listed under `data._omitted`, so a reader can tell "this column was withheld" from "this column did not exist then". Both timings apply the same policy. `.sensitive()` is not involved: it is an export-masking marker for values that are legitimately readable in the app.
104
+ - **@voltro/cli** — A page that RE-EXPORTS its component (`export { default, renderMode } from '../page'`) no longer fails the codegen gate with "exports no default". The check required the literal words `as default`, so the one spelling that lets two routes share a screen without copying it was the one spelling it refused — and it refused in `voltro build`, while dev and tests stayed green because nothing prerenders there. `export { default as Screen }` is still correctly rejected: it renames the default away.
105
+
106
+ Two follow-ons from the same shape:
107
+
108
+ - The refusal message said the file "ends in `.page.tsx`" and offered "drop the `.page` suffix" as a fix. That is the 0.15.0 convention, replaced by directory routing in 0.17.0 — it named a convention that no longer exists and a fix that could not work. It now names `page.tsx` and both real fixes. - `scanRenderProfile` read a forwarded `renderMode` as absent and fell back to `'static'`, so `staticSafe` and the deploy-target classification could call an app CDN-deployable with an `ssr` route in it. The forward is now followed (relative specifiers, depth-capped); an unresolvable one still falls back rather than failing the scan.
109
+ - **@voltro/database** — `VOLTRO_SOFT_DROP=1` could never converge. The applier renames the object to `<name>__dropped_<ts>` instead of dropping it, which leaves it undeclared — and the differ read that as one more forgotten table, planning the drop again. The re-plan inside `applyPlan` then found an operation still outstanding and aborted with "the DDL for these operations is a no-op — this is a framework bug", which was a wrong diagnosis of a real defect: the DDL had worked. No fingerprint was recorded, so the migration counted as unapplied and every later `db apply` / boot hit the same wall. The only exit was a hard drop of the snapshot — exactly the recoverability the flag is chosen for.
110
+
111
+ The planner now treats `<name>__dropped_<YYYYMMDDHHMMSS>` as framework-managed, alongside `_voltro_*` / `cluster_*`. Deliberately not retention-aware: a planner whose output depends on the clock would produce different plans before and after midnight, and `db gc-snapshots` already owns expiry.
112
+
113
+ Reported against tables; the same defect existed one level down for soft-dropped COLUMNS, where it was worse — a re-planned `drop-column` carries no `dropped()` marker and so refuses to plan at all. Both are fixed.
114
+
115
+ The convergence message itself no longer asserts a cause it cannot know. It said "the DDL for these operations is a no-op", which was flatly wrong here and sent the reporter looking for dead DDL. It now names both causes — no-op DDL, and a planner that cannot see what the DDL did — and says which one an operation naming a just-renamed object usually is.
116
+
117
+ ### Internal (no consumer-facing effect)
118
+
119
+ - **The `0.20.0/01_write-recorder-port` codemod gains the gate test its two predecessors have.**
120
+
121
+ `codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. What it cannot see is the one way a `manual` codemod fails in practice: an `appliesTo` that is too broad, so the note prints for projects that have nothing to do. That is not a cosmetic problem. A note which fires on every app is how readers learn to skip notes, and the next one carries a boot refusal.
122
+
123
+ This codemod is the case where the silent direction matters most. The break is a TYPE error, so `tsc` already names every affected site; the note exists only to explain `maxOf`, which the compiler cannot. Apps that merely ENABLE `timing: 'in-transaction'` need to do nothing — `plugin-versioning` ships the recorder and it is already updated — and they are the large majority.
124
+
125
+ Four cases, covering both directions: a project registering its own recorder (note prints, and names `{ append }`, `maxOf`, and the `null`-is-not-zero distinction that a hand-written sequence gets wrong), an app that only enables the timing (silent), the identifier in a comment or a string (silent), and the generic call form `registerWriteRecorder<Row>(…)`, which `callPattern` admits and a naive match would miss.
126
+
127
+ ---
128
+
42
129
  ## [0.19.0] — 2026-07-29
43
130
 
44
131
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -160,6 +160,12 @@ export declare class MssqlStore implements DataStore {
160
160
  * re-enter `routeEvent` and emit a change event for the trail's own table.
161
161
  */
162
162
  private appendInTxn;
163
+ /**
164
+ * The other half of the recorder port: ONE aggregate on the caller's
165
+ * connection, so an append-only trail can number its own entries. Equality
166
+ * filter only — see `writeRecorder.ts` for why it is this narrow.
167
+ */
168
+ private maxInTxn;
163
169
  private routeEvent;
164
170
  query(d: QueryDescriptor): Promise<readonly Readonly<Record<string, unknown>>[]>;
165
171
  raw<T extends object = Row>(fragment: RawSqlFragment, _opts?: {
package/dist/index.js CHANGED
@@ -3,14 +3,14 @@ import { Config as t, Effect as n, Layer as r, ManagedRuntime as i, Option as a,
3
3
  import { EventEmitter as c } from "node:events";
4
4
  import { createLogger as l } from "@voltro/logger";
5
5
  import { SqlClient as u, TransactionConnection as d } from "@effect/sql/SqlClient";
6
- import { CDC_OFFSETS_TABLE as f, EagerCardinalityError as p, _voltroMssqlCdcOffsetsTable as m, attachEagerLoads as h, attributionFields as g, compileEagerJson as _, compilePredicate as v, compileRawFragment as y, compileSelect as b, decodeRowsFromSchema as x, hasEagerLoads as S, isTableReactive as C, qualifyTable as w, raiseChangeListenerCeiling as T, recordsTable as E, requireTable as D, runWriteRecorders as O, stampGeneratedId as k, stampGeneratedIds as A } from "@voltro/database";
6
+ import { CDC_OFFSETS_TABLE as f, EagerCardinalityError as p, _voltroMssqlCdcOffsetsTable as m, attachEagerLoads as h, attributionFields as g, compileEagerJson as _, compilePredicate as v, compileRawFragment as y, compileSelect as b, currentWriteAttribution as x, decodeRowsFromSchema as S, hasEagerLoads as C, isTableReactive as w, qualifyTable as T, raiseChangeListenerCeiling as E, recordsTable as D, requireTable as O, runWithWriteAttribution as k, runWriteRecorders as A, stampGeneratedId as j, stampGeneratedIds as M } from "@voltro/database";
7
7
  //#region src/sqlLayer.ts
8
- var j = {
8
+ var N = {
9
9
  ...e.defaultParameterTypes,
10
10
  null: e.defaultParameterTypes.object
11
- }, M = (n) => e.layerConfig({
11
+ }, P = (n) => e.layerConfig({
12
12
  server: t.succeed(n.server),
13
- parameterTypes: t.succeed(j),
13
+ parameterTypes: t.succeed(N),
14
14
  ...n.port === void 0 ? {} : { port: t.succeed(n.port) },
15
15
  ...n.database === void 0 ? {} : { database: t.succeed(n.database) },
16
16
  ...n.username === void 0 ? {} : { username: t.succeed(n.username) },
@@ -18,7 +18,7 @@ var j = {
18
18
  ...n.encrypt === void 0 ? {} : { encrypt: t.succeed(n.encrypt) },
19
19
  ...n.trustServer === void 0 ? {} : { trustServer: t.succeed(n.trustServer) },
20
20
  ...n.maxConnections === void 0 ? {} : { maxConnections: t.succeed(n.maxConnections) }
21
- }), N = (e) => {
21
+ }), F = (e) => {
22
22
  if (e.url) {
23
23
  let t = new URL(e.url);
24
24
  return {
@@ -40,7 +40,7 @@ var j = {
40
40
  trustServer: !0,
41
41
  ...e.maxConnections === void 0 ? {} : { maxConnections: e.maxConnections }
42
42
  };
43
- }, P = (e) => M(N(e)), F = /* @__PURE__ */ new Set(["1205"]), I = (e) => {
43
+ }, I = (e) => P(F(e)), L = /* @__PURE__ */ new Set(["1205"]), R = (e) => {
44
44
  let t = e;
45
45
  for (let e = 0; e < 5 && typeof t == "object" && t; e++) {
46
46
  let e = t.number;
@@ -49,17 +49,17 @@ var j = {
49
49
  if (typeof n == "string") return n;
50
50
  t = t.cause;
51
51
  }
52
- }, L = (e) => {
53
- let t = I(e);
54
- return t !== void 0 && F.has(t);
55
- }, R = /* @__PURE__ */ new Set(["2601", "2627"]), z = (e) => {
56
- let t = I(e);
57
- return t !== void 0 && R.has(t);
58
- }, B = (e) => L(e) ? "retry" : "noRetry", V = {
52
+ }, z = (e) => {
53
+ let t = R(e);
54
+ return t !== void 0 && L.has(t);
55
+ }, B = /* @__PURE__ */ new Set(["2601", "2627"]), V = (e) => {
56
+ let t = R(e);
57
+ return t !== void 0 && B.has(t);
58
+ }, H = (e) => z(e) ? "retry" : "noRetry", U = {
59
59
  I: "insert",
60
60
  U: "update",
61
61
  D: "delete"
62
- }, H = (e, t) => e(n.gen(function* () {
62
+ }, W = (e, t) => e(n.gen(function* () {
63
63
  let e = yield* u;
64
64
  if (((yield* e`
65
65
  SELECT COUNT(*) AS ${e("on")}
@@ -67,18 +67,18 @@ var j = {
67
67
  WHERE database_id = DB_ID()`)[0]?.on ?? 0) === 0) return yield* n.fail(/* @__PURE__ */ Error("mssql Change Tracking CDC requires CT enabled on the database. Run `ALTER DATABASE <db> SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON)`, or set CDC=0 for inline emit."));
68
68
  for (let n of t) yield* e.unsafe(`IF NOT EXISTS (
69
69
  SELECT 1 FROM sys.change_tracking_tables
70
- WHERE object_id = OBJECT_ID(${U(n)})
70
+ WHERE object_id = OBJECT_ID(${G(n)})
71
71
  )
72
- ALTER TABLE ${W(n)} ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = OFF)`);
73
- })).then(() => void 0), U = (e) => `'${e.replace(/'/g, "''")}'`, W = (e) => `[${e.replace(/\]/g, "]]")}]`, G = async (e) => {
72
+ ALTER TABLE ${K(n)} ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = OFF)`);
73
+ })).then(() => void 0), G = (e) => `'${e.replace(/'/g, "''")}'`, K = (e) => `[${e.replace(/\]/g, "]]")}]`, q = async (e) => {
74
74
  let t = l({ scope: "voltro:mssql:cdc" }), r = e.pollIntervalMs ?? 500;
75
- await H(e.run, e.tables);
75
+ await W(e.run, e.tables);
76
76
  let i = async () => (await e.run(n.gen(function* () {
77
77
  return yield* (yield* u)`SELECT CONVERT(VARCHAR(40), CHANGE_TRACKING_CURRENT_VERSION()) AS v`;
78
78
  })))[0]?.v ?? "0", a = e.startVersion ?? await i(), o = !1, s = null, c = !1, d = (e, t) => BigInt(e) > BigInt(t), f = async (r, o) => {
79
79
  let s = await e.run(n.gen(function* () {
80
- let e = yield* u, t = e.unsafe(W(r)), n = (yield* e`
81
- SELECT CONVERT(VARCHAR(40), CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID(${e.unsafe(U(r))}))) AS floor`)[0]?.floor;
80
+ let e = yield* u, t = e.unsafe(K(r)), n = (yield* e`
81
+ SELECT CONVERT(VARCHAR(40), CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID(${e.unsafe(G(r))}))) AS floor`)[0]?.floor;
82
82
  return n != null && BigInt(o) < BigInt(n) ? {
83
83
  resync: !0,
84
84
  rows: []
@@ -96,7 +96,7 @@ var j = {
96
96
  return;
97
97
  }
98
98
  for (let t of s.rows) {
99
- let n = V[t.__ct_op];
99
+ let n = U[t.__ct_op];
100
100
  if (!n) continue;
101
101
  let { __ct_op: i, __ct_id: a, ...o } = t;
102
102
  if (n === "delete") e.onChange({
@@ -106,7 +106,7 @@ var j = {
106
106
  new: null
107
107
  });
108
108
  else {
109
- let t = x([o], r, "mssql")[0];
109
+ let t = S([o], r, "mssql")[0];
110
110
  e.onChange({
111
111
  table: r,
112
112
  op: n,
@@ -147,12 +147,15 @@ var j = {
147
147
  },
148
148
  currentVersion: () => a
149
149
  };
150
- }, K = l({ scope: "voltro:mssql" }), q = async (e) => {
150
+ }, J = l({ scope: "voltro:mssql" }), Y = async (e) => {
151
151
  let t = e.changeStrategy ?? "inline", n = t;
152
- t === "cdc" && !e.cdcConfig && (K.warn("changeStrategy='cdc' requires cdcConfig (replicaId + includeTables); falling back to 'inline'."), n = "inline");
153
- let a = e.tracerLayer ? r.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, o = i.make(a), s = new J(await o.runPromise(u), o, n);
152
+ t === "cdc" && !e.cdcConfig && (J.warn("changeStrategy='cdc' requires cdcConfig (replicaId + includeTables); falling back to 'inline'."), n = "inline");
153
+ let a = e.tracerLayer ? r.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, o = i.make(a), s = new Z(await o.runPromise(u), o, n);
154
154
  return n === "cdc" && e.cdcConfig && await s.startCdcConsumer(e.cdcConfig), s;
155
- }, J = class e {
155
+ }, X = () => {
156
+ let e = x();
157
+ return e === void 0 ? (e) => e() : (t) => k(e, t);
158
+ }, Z = class e {
156
159
  sql;
157
160
  runtime;
158
161
  changeStrategy;
@@ -165,7 +168,7 @@ var j = {
165
168
  cdcReplicaId = "";
166
169
  cdcStreamName = "default";
167
170
  constructor(e, t, n = "inline", r = null, i) {
168
- this.sql = e, this.runtime = t, this.changeStrategy = n, this.namespace = r, this.emitter = i ?? new c(), T(this.emitter);
171
+ this.sql = e, this.runtime = t, this.changeStrategy = n, this.namespace = r, this.emitter = i ?? new c(), E(this.emitter);
169
172
  }
170
173
  get inlineEmit() {
171
174
  return this.changeStrategy === "inline";
@@ -174,15 +177,15 @@ var j = {
174
177
  return t === this.namespace ? this : new e(this.sql, this.runtime, this.changeStrategy, t, this.emitter);
175
178
  }
176
179
  nsT(e) {
177
- return w(this.namespace, e);
180
+ return T(this.namespace, e);
178
181
  }
179
182
  __mssqlReplicationFriend = { runEffect: (e) => this.runtime.runPromise(e) };
180
183
  async executeQuery(e, t) {
181
184
  let r = b(e, this.sql, this.namespace), i = t ? n.provideService(r, d, t) : r;
182
- return x(await this.runtime.runPromise(i), e.table, "mssql");
185
+ return S(await this.runtime.runPromise(i), e.table, "mssql");
183
186
  }
184
187
  async executeInsert(e, t, r, i) {
185
- t = k(e, t);
188
+ t = j(e, t);
186
189
  let a = this.sql, o = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(t).returning("*")}`, s = r ? n.provideService(o, d, r) : o, c = (await this.runtime.runPromise(s))[0];
187
190
  if (!c) throw Error(`MssqlStore.insert: row not returned post-insert in '${e}'`);
188
191
  return await this.routeEvent({
@@ -202,7 +205,7 @@ var j = {
202
205
  }, a, i), l) : null;
203
206
  }
204
207
  async executeInsertMany(e, t, r, i) {
205
- if (t = A(e, t), t.length === 0) return [];
208
+ if (t = M(e, t), t.length === 0) return [];
206
209
  let a = this.sql, o = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(t).returning("*")}`, s = r ? n.provideService(o, d, r) : o, c = await this.runtime.runPromise(s), l = t.map((e) => e.id), u = new Map(c.map((e) => [e.id, e])), f = l.map((e) => u.get(e)).filter((e) => e !== void 0), p = f.length === c.length ? f : [...c];
207
210
  for (let t of p) await this.routeEvent({
208
211
  table: e,
@@ -234,18 +237,25 @@ var j = {
234
237
  let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(t)}`;
235
238
  await this.runtime.runPromise(r ? n.provideService(a, d, r) : a);
236
239
  }
240
+ async maxInTxn(e, t, r, i) {
241
+ let a = this.sql, o = Object.entries(r).map(([e, t]) => a`${a(e)} = ${t}`), s = a`SELECT MAX(${a(t)}) AS ${a("m")} FROM ${a(this.nsT(e))} WHERE ${a.and(o)}`, c = (await this.runtime.runPromise(i ? n.provideService(s, d, i) : s))[0]?.m;
242
+ return c == null ? null : Number(c);
243
+ }
237
244
  async routeEvent(e, t, n = null) {
238
245
  e = {
239
246
  ...g(),
240
247
  ...e
241
- }, E(e.table) && await O((e, t) => this.appendInTxn(e, t, n), {
248
+ }, D(e.table) && await A({
249
+ append: (e, t) => this.appendInTxn(e, t, n),
250
+ maxOf: (e, t, r) => this.maxInTxn(e, t, r, n)
251
+ }, {
242
252
  table: e.table,
243
253
  op: e.op,
244
254
  next: e.new,
245
255
  prev: e.old,
246
256
  traceId: e.traceId,
247
257
  subjectId: e.subjectId
248
- }), C(e.table) && (t === null ? this.inlineEmit && this.emitter.emit("change", e) : t.push(e));
258
+ }), w(e.table) && (t === null ? this.inlineEmit && this.emitter.emit("change", e) : t.push(e));
249
259
  }
250
260
  query(e) {
251
261
  return this.runWithEager(e, null);
@@ -255,34 +265,34 @@ var j = {
255
265
  return this.runtime.runPromise(n);
256
266
  }
257
267
  async runWithEager(e, t) {
258
- if (!S(e)) return this.executeQuery(e, t);
268
+ if (!C(e)) return this.executeQuery(e, t);
259
269
  let r = this.namespace === null ? _(e, this.sql, "mssql") : null;
260
270
  if (r !== null) try {
261
271
  let e = t ? n.provideService(r.fragment, d, t) : r.fragment, i = await this.runtime.runPromise(e);
262
272
  return r.decode(i);
263
273
  } catch (e) {
264
274
  if (e instanceof p) throw e;
265
- K.warn("mssql JSON-agg eager-load failed; falling back to walker", { err: e });
275
+ J.warn("mssql JSON-agg eager-load failed; falling back to walker", { err: e });
266
276
  }
267
- return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? D(e.table), (e) => this.executeQuery(e, t));
277
+ return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? O(e.table), (e) => this.executeQuery(e, t));
268
278
  }
269
279
  getInternalRunWithEager() {
270
280
  return this.runWithEager.bind(this);
271
281
  }
272
282
  insert(e, t) {
273
- return this.executeInsert(e, t, null, null);
283
+ return X()(() => this.executeInsert(e, t, null, null));
274
284
  }
275
285
  insertMany(e, t) {
276
- return this.executeInsertMany(e, t, null, null);
286
+ return X()(() => this.executeInsertMany(e, t, null, null));
277
287
  }
278
288
  patchJson(e, t, n, r) {
279
- return this.executePatchJson(e, t, n, r, null, null);
289
+ return X()(() => this.executePatchJson(e, t, n, r, null, null));
280
290
  }
281
291
  update(e, t, n) {
282
- return this.executeUpdate(e, t, n, null, null);
292
+ return X()(() => this.executeUpdate(e, t, n, null, null));
283
293
  }
284
294
  delete(e, t) {
285
- return this.executeDelete(e, t, null, null);
295
+ return X()(() => this.executeDelete(e, t, null, null));
286
296
  }
287
297
  async updateMany(e, t, n) {
288
298
  let r = this.sql, i = v(n.where, r, this.namespace), a = r`UPDATE ${r(this.nsT(e))} SET ${r.update(t)} OUTPUT INSERTED.* WHERE ${i}`, o = await this.runtime.runPromise(a);
@@ -305,10 +315,10 @@ var j = {
305
315
  return a.length;
306
316
  }
307
317
  upsert(e, t, n) {
308
- return this.executeUpsert(e, t, n, null, null);
318
+ return X()(() => this.executeUpsert(e, t, n, null, null));
309
319
  }
310
320
  insertIgnore(e, t, n) {
311
- return this.executeInsertIgnore(e, t, n, null, null);
321
+ return X()(() => this.executeInsertIgnore(e, t, n, null, null));
312
322
  }
313
323
  upsertPatch(e, t, n) {
314
324
  if (typeof n.update == "function") return n.update(t);
@@ -331,7 +341,7 @@ var j = {
331
341
  try {
332
342
  return await this.executeInsert(e, t, r, i);
333
343
  } catch (a) {
334
- if (!z(a)) throw a;
344
+ if (!V(a)) throw a;
335
345
  let o = await this.findByConflict(e, t, n.conflictColumns, r);
336
346
  if (!o) throw a;
337
347
  return await this.executeUpdate(e, o.id, this.upsertPatch(t, o, n), r, i) ?? o;
@@ -355,13 +365,13 @@ var j = {
355
365
  }, a), x;
356
366
  }
357
367
  async executeInsertIgnore(e, t, n, r, i) {
358
- t = k(e, t);
368
+ t = j(e, t);
359
369
  let a = await this.findByConflict(e, t, n.conflictColumns, r);
360
370
  if (a) return a;
361
371
  try {
362
372
  return await this.executeInsert(e, t, r, i);
363
373
  } catch (i) {
364
- if (!z(i)) throw i;
374
+ if (!V(i)) throw i;
365
375
  let a = await this.findByConflict(e, t, n.conflictColumns, r);
366
376
  if (!a) throw i;
367
377
  return a;
@@ -379,9 +389,9 @@ var j = {
379
389
  if (this.cdcHandle) return;
380
390
  this.cdcReplicaId = e.replicaId;
381
391
  let t = e.includeTables ?? [];
382
- t.length === 0 && K.warn("cdc: no reactive tables to tail; Change Tracking reader idle. Set includeTables to enable.");
392
+ t.length === 0 && J.warn("cdc: no reactive tables to tail; Change Tracking reader idle. Set includeTables to enable.");
383
393
  let n = await this.readCdcOffset(e.replicaId);
384
- this.cdcHandle = await G({
394
+ this.cdcHandle = await q({
385
395
  run: this.__mssqlReplicationFriend.runEffect,
386
396
  tables: t,
387
397
  startVersion: n,
@@ -389,9 +399,9 @@ var j = {
389
399
  onVersion: (e) => {
390
400
  this.cdcPendingVersion = e;
391
401
  },
392
- onError: (e) => K.warn("cdc: consumer error", {}, e),
402
+ onError: (e) => J.warn("cdc: consumer error", {}, e),
393
403
  onResync: () => {
394
- K.warn("cdc: resynced to current CT version after retention gap — subscriptions self-heal on the next change");
404
+ J.warn("cdc: resynced to current CT version after retention gap — subscriptions self-heal on the next change");
395
405
  }
396
406
  }), this.cdcCheckpointTimer = setInterval(() => {
397
407
  this.flushCdcOffset();
@@ -402,12 +412,12 @@ var j = {
402
412
  let t = this.sql, n = (await this.runtime.runPromise(t`
403
413
  SELECT TOP 1 ${t("ctVersion")} FROM ${t(f)}
404
414
  WHERE ${t("id")} = ${e}`))[0]?.ctVersion;
405
- return n ? (K.info("cdc: resuming from persisted CT version", {
415
+ return n ? (J.info("cdc: resuming from persisted CT version", {
406
416
  replicaId: e,
407
417
  version: n
408
418
  }), n) : null;
409
419
  } catch (t) {
410
- return K.warn("cdc: no readable offset; starting at current CT version", { replicaId: e }, t), null;
420
+ return J.warn("cdc: no readable offset; starting at current CT version", { replicaId: e }, t), null;
411
421
  }
412
422
  }
413
423
  async flushCdcOffset() {
@@ -427,7 +437,7 @@ var j = {
427
437
  update: ["ctVersion", "updatedAt"]
428
438
  }, null, null);
429
439
  } catch (t) {
430
- this.cdcPendingVersion = e, K.warn("cdc: checkpoint write failed", {}, t);
440
+ this.cdcPendingVersion = e, J.warn("cdc: checkpoint write failed", {}, t);
431
441
  }
432
442
  }
433
443
  }
@@ -457,26 +467,26 @@ var j = {
457
467
  }
458
468
  async transactional(e) {
459
469
  this.inflightTxns++;
460
- let t = 0, r = n.suspend(() => {
461
- let r = ++t;
462
- return this.sql.withTransaction(n.flatMap(n.serviceOption(d), (t) => {
463
- if (a.isNone(t)) return n.fail(/* @__PURE__ */ Error("MssqlStore.transactional: TransactionConnection missing."));
464
- let i = new Y(this, t.value);
470
+ let t = X(), r = 0, i = n.suspend(() => {
471
+ let i = ++r;
472
+ return this.sql.withTransaction(n.flatMap(n.serviceOption(d), (r) => {
473
+ if (a.isNone(r)) return n.fail(/* @__PURE__ */ Error("MssqlStore.transactional: TransactionConnection missing."));
474
+ let o = new Q(this, r.value);
465
475
  return n.tryPromise({
466
- try: () => e(i).then((e) => ({
476
+ try: () => t(() => e(o)).then((e) => ({
467
477
  result: e,
468
- view: i,
469
- attempt: r
478
+ view: o,
479
+ attempt: i
470
480
  })),
471
481
  catch: (e) => e
472
482
  });
473
483
  }));
474
- }), i = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(L)), o = r.pipe(n.retry(i), n.withSpan("store.transactional", { attributes: {
484
+ }), o = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(z)), c = i.pipe(n.retry(o), n.withSpan("store.transactional", { attributes: {
475
485
  "db.system": "mssql",
476
486
  "db.operation": "transaction"
477
487
  } }));
478
488
  try {
479
- let e = await this.runtime.runPromise(o);
489
+ let e = await this.runtime.runPromise(c);
480
490
  return e.view.commitEvents(), e.result;
481
491
  } finally {
482
492
  this.inflightTxns--;
@@ -491,7 +501,7 @@ var j = {
491
501
  return this.changeStrategy === "cdc" ? "fleet" : "local";
492
502
  }
493
503
  injectExternalChange(e) {
494
- C(e.table) && this.emitter.emit("change", {
504
+ w(e.table) && this.emitter.emit("change", {
495
505
  ...e,
496
506
  origin: "injected"
497
507
  });
@@ -507,7 +517,7 @@ var j = {
507
517
  if (this.inflightTxns > 0) {
508
518
  let t = Date.now() + e;
509
519
  for (; this.inflightTxns > 0 && Date.now() < t;) await new Promise((e) => setTimeout(e, 25));
510
- this.inflightTxns > 0 && K.warn("close: grace period expired with in-flight transactions", {
520
+ this.inflightTxns > 0 && J.warn("close: grace period expired with in-flight transactions", {
511
521
  gracePeriodMs: e,
512
522
  inflight: this.inflightTxns
513
523
  });
@@ -517,7 +527,7 @@ var j = {
517
527
  async ping() {
518
528
  await this.runtime.runPromise(this.sql`SELECT 1`);
519
529
  }
520
- }, Y = class {
530
+ }, Q = class {
521
531
  parent;
522
532
  txn;
523
533
  events = [];
@@ -589,9 +599,9 @@ var j = {
589
599
  this.events.length = 0;
590
600
  }
591
601
  }
592
- }, X = (e) => e.__mssqlReplicationFriend ?? null, Z = (e, t) => e === t ? 0 : e < t ? -1 : 1, Q = () => ({
602
+ }, $ = (e) => e.__mssqlReplicationFriend ?? null, ee = (e, t) => e === t ? 0 : e < t ? -1 : 1, te = () => ({
593
603
  async capturePrimaryPosition(e) {
594
- let t = X(e);
604
+ let t = $(e);
595
605
  if (t === null) throw Error("mssqlReplicationAdapter: primary is not an MssqlStore (missing __mssqlReplicationFriend).");
596
606
  return t.runEffect(n.gen(function* () {
597
607
  return (yield* (yield* u)`
@@ -604,7 +614,7 @@ var j = {
604
614
  }));
605
615
  },
606
616
  async probeReplicaPosition(e) {
607
- let t = X(e);
617
+ let t = $(e);
608
618
  if (t === null) throw Error("mssqlReplicationAdapter: replica is not an MssqlStore.");
609
619
  return t.runEffect(n.gen(function* () {
610
620
  return (yield* (yield* u)`
@@ -616,14 +626,14 @@ var j = {
616
626
  }));
617
627
  },
618
628
  compare(e, t) {
619
- return Z(t, e) >= 0 ? "caught-up" : "behind";
629
+ return ee(t, e) >= 0 ? "caught-up" : "behind";
620
630
  }
621
- }), $ = {
631
+ }), ne = {
622
632
  id: "mssql",
623
- makeSqlLayer: (e) => P(e),
624
- makeStore: (e) => q(e),
633
+ makeSqlLayer: (e) => I(e),
634
+ makeStore: (e) => Y(e),
625
635
  compileContains: (e, t, n) => n`${e} LIKE ${`%${t.replace(/[\\%_[\]]/g, (e) => `\\${e}`)}%`} ESCAPE '\\'`,
626
- retryFilter: B
636
+ retryFilter: H
627
637
  };
628
638
  //#endregion
629
- export { f as CDC_OFFSETS_TABLE, e as MssqlClient, m as _voltroMssqlCdcOffsetsTable, N as connectionFromConfig, H as ensureChangeTracking, M as makeMssqlSqlLayer, P as makeMssqlSqlLayerFromConfig, q as makeMssqlStore, $ as mssqlDialect, Q as mssqlReplicationAdapter, B as mssqlRetryFilter, G as startChangeTrackingCdc };
639
+ export { f as CDC_OFFSETS_TABLE, e as MssqlClient, m as _voltroMssqlCdcOffsetsTable, F as connectionFromConfig, W as ensureChangeTracking, P as makeMssqlSqlLayer, I as makeMssqlSqlLayerFromConfig, Y as makeMssqlStore, ne as mssqlDialect, te as mssqlReplicationAdapter, H as mssqlRetryFilter, q as startChangeTrackingCdc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/sql-mssql",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "SQL Server (mssql) dialect adapter for Voltro's cross-dialect DataStore (OUTPUT-clause returning, OFFSET/FETCH paging, inline reactivity).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -34,8 +34,8 @@
34
34
  "dependencies": {
35
35
  "@effect/sql": "^0.51.1",
36
36
  "@effect/sql-mssql": "^0.52.0",
37
- "@voltro/database": "0.19.0",
38
- "@voltro/logger": "0.19.0"
37
+ "@voltro/database": "0.20.0",
38
+ "@voltro/logger": "0.20.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "effect": "^3.21.4"