@blamejs/core 0.4.1

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 (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. package/package.json +39 -0
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ /**
3
+ * Default cluster-coordination provider — DB-row-based leader election.
4
+ *
5
+ * Uses an externalDb backend (already configured via b.externalDb.init)
6
+ * as the coordination point. A single row in `_blamejs_leader` holds
7
+ * the current lease; acquireLease is `INSERT ... ON CONFLICT ... DO
8
+ * UPDATE WHERE expiresAt < now() RETURNING ...`, which is atomic in
9
+ * Postgres and SQLite.
10
+ *
11
+ * Fencing tokens: every successful acquire bumps a monotonic integer.
12
+ * Leader-only DB writes include the current token; the audit-tip row's
13
+ * CHECK constraint rejects any incoming token below the stored one,
14
+ * which fences out a partitioned old leader even if its application-
15
+ * layer `_requireLeader()` gate somehow allowed the call through.
16
+ *
17
+ * Dialects: Postgres and SQLite use identical surface SQL. MySQL's
18
+ * `ON DUPLICATE KEY UPDATE` doesn't support a WHERE clause; a MySQL
19
+ * fallback (SELECT ... FOR UPDATE inside an explicit transaction) is
20
+ * not yet implemented and operators on MySQL must supply their own
21
+ * provider until it lands.
22
+ *
23
+ * Public API:
24
+ * create({ externalDbBackend, dialect? }) → provider instance
25
+ *
26
+ * Provider instance:
27
+ * ensureSchema() async; idempotent CREATE
28
+ * TABLE + ALTER for
29
+ * endpoint migration
30
+ * acquireLease(nodeId, leaseTtlMs, opts?) async; → Lease | null.
31
+ * opts.endpoint persists
32
+ * in the row for discovery.
33
+ * renewLease(lease, opts?) async; → Lease (throws on
34
+ * takeover). opts.endpoint
35
+ * refreshes the row.
36
+ * releaseLease(lease) async; → void
37
+ * currentLeader() async; →
38
+ * { nodeId, leaseExpiresAt,
39
+ * fencingToken,
40
+ * endpoint } | null
41
+ *
42
+ * Lease object shape:
43
+ * { nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint }
44
+ */
45
+ var { generateToken } = require("./crypto");
46
+ var externalDb = require("./external-db");
47
+ var { ClusterProviderError } = require("./framework-error");
48
+
49
+ var _err = ClusterProviderError.factory;
50
+
51
+ function create(config) {
52
+ if (!config || !config.externalDbBackend) {
53
+ throw _err("INVALID_CONFIG",
54
+ "cluster-provider-db requires { externalDbBackend: <name> }", true);
55
+ }
56
+ var backendName = config.externalDbBackend;
57
+ var dialect = (config.dialect || "postgres").toLowerCase();
58
+ if (dialect !== "postgres" && dialect !== "sqlite") {
59
+ throw _err("UNSUPPORTED_DIALECT",
60
+ "cluster-provider-db dialect must be 'postgres' or 'sqlite' (got: " + dialect + ")",
61
+ true);
62
+ }
63
+
64
+ // Postgres uses $1/$2 placeholders; SQLite accepts the same so we use
65
+ // them uniformly. If a future dialect needs ?-placeholders, swap here.
66
+ function _placeholder(n) { return "$" + n; }
67
+
68
+ function _q(sql, params) {
69
+ return externalDb.query(sql, params || [], { backend: backendName });
70
+ }
71
+
72
+ async function ensureSchema() {
73
+ // Postgres: BIGINT for ms-precision timestamps. SQLite: INTEGER works
74
+ // for either; pick INTEGER so the same DDL parses on both.
75
+ var intType = dialect === "postgres" ? "BIGINT" : "INTEGER";
76
+
77
+ await _q(
78
+ "CREATE TABLE IF NOT EXISTS _blamejs_leader (" +
79
+ " scope TEXT PRIMARY KEY," +
80
+ " nodeId TEXT NOT NULL," +
81
+ " leaseId TEXT NOT NULL," +
82
+ " acquiredAt " + intType + " NOT NULL," +
83
+ " expiresAt " + intType + " NOT NULL," +
84
+ " fencingToken " + intType + " NOT NULL," +
85
+ " endpoint TEXT," +
86
+ " CHECK (scope = 'leader')" +
87
+ ")"
88
+ );
89
+ // Migration for installs that pre-date the endpoint column. Both
90
+ // Postgres (≥9.6) and SQLite (≥3.35, March 2021) support ADD COLUMN
91
+ // IF NOT EXISTS, but we go through try/catch to keep the path
92
+ // dialect-version-agnostic — the only "expected" failure here is
93
+ // "column already exists," which we swallow.
94
+ try {
95
+ await _q("ALTER TABLE _blamejs_leader ADD COLUMN endpoint TEXT");
96
+ } catch (_e) { /* column already exists — fine */ }
97
+
98
+ // _blamejs_cluster_state — single-row consistency check used to
99
+ // detect vault-key drift across cluster nodes. Same single-row
100
+ // invariant pattern as _blamejs_leader (PRIMARY KEY on a
101
+ // constant-valued scope column + CHECK).
102
+ await _q(
103
+ "CREATE TABLE IF NOT EXISTS _blamejs_cluster_state (" +
104
+ " scope TEXT PRIMARY KEY," +
105
+ " vaultKeyFp TEXT NOT NULL," +
106
+ " recordedAt " + intType + " NOT NULL," +
107
+ " recordedByNode TEXT NOT NULL," +
108
+ " CHECK (scope = 'state')" +
109
+ ")"
110
+ );
111
+ }
112
+
113
+ async function acquireLease(nodeId, leaseTtlMs, opts) {
114
+ if (!nodeId) throw _err("INVALID_NODE_ID", "nodeId required", true);
115
+ if (typeof leaseTtlMs !== "number" || leaseTtlMs <= 0) {
116
+ throw _err("INVALID_TTL", "leaseTtlMs must be a positive number", true);
117
+ }
118
+ var endpoint = (opts && opts.endpoint) || null;
119
+ var leaseId = generateToken(16);
120
+ var nowMs = Date.now();
121
+ var expiresAt = nowMs + leaseTtlMs;
122
+
123
+ // Atomic acquire: insert if no row, OR steal if existing row's
124
+ // expiresAt has passed. Bump fencingToken on every successful
125
+ // mutation. RETURNING gives us the post-write state — which we
126
+ // compare to our nodeId to know whether we won.
127
+ var sql =
128
+ "INSERT INTO _blamejs_leader " +
129
+ " (scope, nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint) " +
130
+ "VALUES " +
131
+ " ('leader', " + _placeholder(1) + ", " + _placeholder(2) + ", " +
132
+ " " + _placeholder(3) + ", " + _placeholder(4) + ", 1, " + _placeholder(5) + ") " +
133
+ "ON CONFLICT (scope) DO UPDATE SET " +
134
+ " nodeId = EXCLUDED.nodeId," +
135
+ " leaseId = EXCLUDED.leaseId," +
136
+ " acquiredAt = EXCLUDED.acquiredAt," +
137
+ " expiresAt = EXCLUDED.expiresAt," +
138
+ " fencingToken = _blamejs_leader.fencingToken + 1," +
139
+ " endpoint = EXCLUDED.endpoint " +
140
+ "WHERE _blamejs_leader.expiresAt < " + _placeholder(6) + " " +
141
+ "RETURNING nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint";
142
+
143
+ var result = await _q(sql, [nodeId, leaseId, nowMs, expiresAt, endpoint, nowMs]);
144
+
145
+ if (!result.rows || result.rows.length === 0) {
146
+ // The WHERE clause excluded the row — someone else still holds.
147
+ return null;
148
+ }
149
+ var row = result.rows[0];
150
+ if (row.nodeId !== nodeId || row.leaseId !== leaseId) {
151
+ // Another node won the race (RETURNING showed their values, not ours).
152
+ return null;
153
+ }
154
+ return {
155
+ nodeId: row.nodeId,
156
+ leaseId: row.leaseId,
157
+ acquiredAt: Number(row.acquiredAt),
158
+ expiresAt: Number(row.expiresAt),
159
+ fencingToken: Number(row.fencingToken),
160
+ endpoint: row.endpoint || null,
161
+ };
162
+ }
163
+
164
+ async function renewLease(lease, opts) {
165
+ if (!lease || !lease.leaseId) throw _err("INVALID_LEASE", "lease required", true);
166
+ var nowMs = Date.now();
167
+ var newExpiresAt = nowMs + (lease.expiresAt - lease.acquiredAt);
168
+ // opts.endpoint, when provided, refreshes the stored endpoint so
169
+ // operators who hot-update their config see the discovery row catch
170
+ // up. Default = preserve whatever was stored at acquire time.
171
+ var endpoint = (opts && opts.endpoint !== undefined) ? opts.endpoint : lease.endpoint || null;
172
+
173
+ // Match on (nodeId, leaseId) so a takeover is detectable: if our
174
+ // leaseId is no longer in the row, 0 rows update and we throw
175
+ // NotLeader. Don't bump fencingToken on renewal — only on a fresh
176
+ // acquire.
177
+ var sql =
178
+ "UPDATE _blamejs_leader SET " +
179
+ " expiresAt = " + _placeholder(1) + "," +
180
+ " endpoint = " + _placeholder(2) + " " +
181
+ "WHERE scope = 'leader' AND nodeId = " + _placeholder(3) +
182
+ " AND leaseId = " + _placeholder(4) + " " +
183
+ "RETURNING nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint";
184
+
185
+ var result = await _q(sql, [newExpiresAt, endpoint, lease.nodeId, lease.leaseId]);
186
+ if (!result.rows || result.rows.length === 0) {
187
+ throw _err("LEASE_LOST",
188
+ "lease for node '" + lease.nodeId + "' was taken over (renewal rejected)",
189
+ false);
190
+ }
191
+ var row = result.rows[0];
192
+ return {
193
+ nodeId: row.nodeId,
194
+ leaseId: row.leaseId,
195
+ acquiredAt: Number(row.acquiredAt),
196
+ expiresAt: Number(row.expiresAt),
197
+ fencingToken: Number(row.fencingToken),
198
+ endpoint: row.endpoint || null,
199
+ };
200
+ }
201
+
202
+ async function releaseLease(lease) {
203
+ if (!lease || !lease.leaseId) return;
204
+ // Clear our row so the next acquire wins immediately. Match on
205
+ // leaseId so a takeover-then-release race doesn't clear someone
206
+ // else's lease.
207
+ var sql =
208
+ "UPDATE _blamejs_leader SET " +
209
+ " expiresAt = 0 " +
210
+ "WHERE scope = 'leader' AND nodeId = " + _placeholder(1) +
211
+ " AND leaseId = " + _placeholder(2);
212
+ await _q(sql, [lease.nodeId, lease.leaseId]);
213
+ }
214
+
215
+ async function currentLeader() {
216
+ var result = await _q(
217
+ "SELECT nodeId, expiresAt, fencingToken, endpoint FROM _blamejs_leader " +
218
+ "WHERE scope = 'leader'"
219
+ );
220
+ if (!result.rows || result.rows.length === 0) return null;
221
+ var row = result.rows[0];
222
+ if (Number(row.expiresAt) < Date.now()) return null;
223
+ return {
224
+ nodeId: row.nodeId,
225
+ leaseExpiresAt: Number(row.expiresAt),
226
+ fencingToken: Number(row.fencingToken),
227
+ endpoint: row.endpoint || null,
228
+ };
229
+ }
230
+
231
+ return {
232
+ kind: "db",
233
+ backendName: backendName,
234
+ dialect: dialect,
235
+ ensureSchema: ensureSchema,
236
+ acquireLease: acquireLease,
237
+ renewLease: renewLease,
238
+ releaseLease: releaseLease,
239
+ currentLeader: currentLeader,
240
+ };
241
+ }
242
+
243
+ module.exports = {
244
+ create: create,
245
+ };
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ /**
3
+ * Framework-state SQL dispatch — runs against local SQLite in single-
4
+ * node mode and against external-db in cluster mode.
5
+ *
6
+ * audit / consent / sessions / queue / subject all read and write the
7
+ * framework's own tables (audit_log, consent_log, …). In single-node
8
+ * mode those tables live in the framework's own SQLite (lib/db.js).
9
+ * In cluster mode they live in the operator-supplied external-db with
10
+ * a `_blamejs_` prefix to avoid colliding with app tables.
11
+ *
12
+ * This module is the dispatch primitive. Callers write SQL once using
13
+ * unprefixed table names + `?` placeholders; the dispatcher translates
14
+ * to the active backend's flavor:
15
+ *
16
+ * single-node local SQLite via db().prepare(sql).run/get/all(...)
17
+ * cluster (sqlite) externalDb.query(sql, params) ? placeholders
18
+ * cluster (postgres) externalDb.query(translated, params) $1, $2, …
19
+ *
20
+ * Tables are translated through frameworkSchema.tableName so callers
21
+ * use logical names (audit_log) and the resolved name is automatically
22
+ * prefixed in cluster mode (_blamejs_audit_log).
23
+ *
24
+ * The dispatcher is async-only — the operator's external-db driver
25
+ * is async, and even local-SQLite calls return a resolved Promise to
26
+ * keep the call shape uniform. Callers `await` this module's methods;
27
+ * audit / consent / queue / session / subject all thread `async` /
28
+ * `await` through their own surfaces to match.
29
+ *
30
+ * Public API:
31
+ * await execute(sql, params?) { rows, rowCount }
32
+ * tableName(local) external-db prefixed name (or unchanged
33
+ * in single-node mode)
34
+ * placeholderize(sql, dialect) `?` to `$N` for postgres; passthrough
35
+ * for sqlite
36
+ * resolveTables(sql) rewrites bare unprefixed table names
37
+ * in cluster mode (only the framework's
38
+ * known tables are rewritten — operator
39
+ * app-data SQL is unaffected)
40
+ */
41
+
42
+ var cluster = require("./cluster");
43
+ var frameworkSchema = require("./framework-schema");
44
+ var externalDb = require("./external-db");
45
+ var lazyRequire = require("./lazy-require");
46
+ var { FrameworkError } = require("./framework-error");
47
+
48
+ class ClusterStorageError extends FrameworkError {
49
+ constructor(message, code) {
50
+ super(message);
51
+ this.name = "ClusterStorageError";
52
+ this.code = code || "cluster-storage/invalid";
53
+ this.isClusterStorageError = true;
54
+ }
55
+ }
56
+
57
+ // ---- Lazy db ref to avoid circular require ----
58
+ var _localDb = lazyRequire(function () { return require("./db"); });
59
+
60
+ // ---- Table-name resolution ----
61
+
62
+ function tableName(local) {
63
+ if (cluster.isClusterMode()) return frameworkSchema.tableName(local);
64
+ return local;
65
+ }
66
+
67
+ // Rewrite bare table names in SQL when running in cluster mode. We only
68
+ // touch tokens that are exactly one of the framework's known table names
69
+ // (audit_log, consent_log, …) — anything else passes through unchanged
70
+ // so app-data SQL composed via this dispatcher (or operator-written
71
+ // migrations) isn't rewritten by accident.
72
+ function resolveTables(sql) {
73
+ if (!cluster.isClusterMode()) return sql;
74
+ var mapping = frameworkSchema.LOCAL_TO_EXTERNAL;
75
+ // Order longest-first so prefix matches don't collide (audit_log
76
+ // before audit). Match on word boundaries so we don't rewrite a
77
+ // suffix of a longer identifier.
78
+ var localNames = Object.keys(mapping).sort(function (a, b) { return b.length - a.length; });
79
+ var translated = sql;
80
+ for (var i = 0; i < localNames.length; i++) {
81
+ var local = localNames[i];
82
+ var external = mapping[local];
83
+ if (local === external) continue; // identity mapping — no-op
84
+ var re = new RegExp("\\b" + local.replace(/[\\.-]/g, "\\$&") + "\\b", "g");
85
+ translated = translated.replace(re, external);
86
+ }
87
+ return translated;
88
+ }
89
+
90
+ // ---- Placeholder translation ----
91
+ //
92
+ // SQLite and Postgres both accept `?` in some contexts, but Postgres
93
+ // only accepts `$N` for parameter binding. We translate at dispatch
94
+ // time so callers always write `?` and the right thing happens per
95
+ // dialect.
96
+
97
+ function placeholderize(sql, dialect) {
98
+ if (dialect !== "postgres") return sql;
99
+ // Walk the SQL and replace `?` with $1, $2, … but skip ones inside
100
+ // single-quoted string literals.
101
+ var out = "";
102
+ var n = 0;
103
+ var inStr = false;
104
+ for (var i = 0; i < sql.length; i++) {
105
+ var c = sql.charAt(i);
106
+ if (c === "'" && !inStr) { inStr = true; out += c; continue; }
107
+ if (c === "'" && inStr) {
108
+ // Handle escaped '' inside string
109
+ if (sql.charAt(i + 1) === "'") { out += "''"; i += 1; continue; }
110
+ inStr = false; out += c; continue;
111
+ }
112
+ if (!inStr && c === "?") { n += 1; out += "$" + n; continue; }
113
+ out += c;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // ---- execute() ----
119
+
120
+ async function execute(sql, params) {
121
+ if (typeof sql !== "string") {
122
+ throw new ClusterStorageError("sql must be a string", "cluster-storage/bad-arg");
123
+ }
124
+ params = params || [];
125
+
126
+ if (cluster.isClusterMode()) {
127
+ var translated = placeholderize(resolveTables(sql), cluster.dialect());
128
+ var result = await externalDb.query(translated, params, {
129
+ backend: cluster.externalDbBackend(),
130
+ });
131
+ return result;
132
+ }
133
+
134
+ // Local SQLite path. node:sqlite is sync — wrap in a resolved Promise
135
+ // so callers always see the same shape regardless of mode.
136
+ var stmt = _localDb().prepare(sql);
137
+ // Heuristic: if the statement returns rows (SELECT or has RETURNING),
138
+ // use .all(); otherwise .run() and report changes as rowCount.
139
+ if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
140
+ var rows = stmt.all.apply(stmt, params);
141
+ return { rows: rows, rowCount: rows.length };
142
+ }
143
+ var info = stmt.run.apply(stmt, params);
144
+ return { rows: [], rowCount: info.changes };
145
+ }
146
+
147
+ // Convenience wrappers for the two common patterns.
148
+ async function executeOne(sql, params) {
149
+ var result = await execute(sql, params);
150
+ return result.rows.length > 0 ? result.rows[0] : null;
151
+ }
152
+
153
+ async function executeAll(sql, params) {
154
+ var result = await execute(sql, params);
155
+ return result.rows;
156
+ }
157
+
158
+ module.exports = {
159
+ execute: execute,
160
+ executeOne: executeOne,
161
+ executeAll: executeAll,
162
+ tableName: tableName,
163
+ resolveTables: resolveTables,
164
+ placeholderize: placeholderize,
165
+ ClusterStorageError: ClusterStorageError,
166
+ };