@everystack/server 0.4.30 → 0.4.32

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/src/sql-lex.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A dollar-quote-, string-, and comment-aware SQL lexer — deliberately DUPLICATED from
3
+ * `packages/cli/src/cli/derived-source.ts`'s `lex`/`splitSqlStatements`, not imported.
4
+ *
5
+ * `@everystack/server` ships into a consumer's Lambda; `@everystack/cli` is a devDependency
6
+ * there (see `packages/server/src/ssr.ts` for the one place server crosses this boundary
7
+ * today, TYPE-ONLY). Pulling a runtime function across that boundary would make `@everystack/cli`
8
+ * a real runtime dependency of every server bundle, for a handful of lexer lines — so this is a
9
+ * deliberate fork, not an oversight. SQL comment/string/dollar-quote grammar is stable; a future
10
+ * change to the CLI's copy (a new quoting form, a bug fix) needs its counterpart applied here by
11
+ * hand. `packages/server/__tests__/sql-lex.test.ts` exercises this copy directly (comments,
12
+ * strings, dollar-quoted bodies, nested block comments) so a REGRESSION in this file is caught
13
+ * even without a cross-package comparison.
14
+ *
15
+ * The E-string fix below (an `E`/`e` prefix only counts when it stands alone before the quote)
16
+ * now lives in BOTH copies — the CLI's `lex` also feeds `db:exec` and `db:backfill`, which run a
17
+ * file one statement at a time, so a miscounted boundary sends the wrong text to the database.
18
+ * Each copy carries the same cases in its own test file.
19
+ *
20
+ * Used by `db:query`'s write guard (plugin.ts): a payload comment-prefixed before a write
21
+ * keyword, or two statements joined by `;` where only the first is a harmless read, must not
22
+ * defeat a `^`-anchored keyword regex the way a plain `.trim()` does.
23
+ */
24
+
25
+ export type LexState =
26
+ | { in: 'normal' }
27
+ | { in: 'single'; escaped: boolean }
28
+ | { in: 'double' }
29
+ | { in: 'dollar'; tag: string }
30
+ | { in: 'line-comment' }
31
+ | { in: 'block-comment'; depth: number };
32
+
33
+ function dollarTagAt(text: string, i: number): string | null {
34
+ if (text[i] !== '$') return null;
35
+ const m = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(i, i + 64));
36
+ return m ? m[0] : null;
37
+ }
38
+
39
+ export function lex(
40
+ text: string,
41
+ emit: (ch: string, state: LexState['in'], index: number) => void,
42
+ ): void {
43
+ let state: LexState = { in: 'normal' };
44
+ let i = 0;
45
+ while (i < text.length) {
46
+ const ch = text[i];
47
+ const next = text[i + 1];
48
+
49
+ if (state.in === 'normal') {
50
+ if (ch === '-' && next === '-') {
51
+ state = { in: 'line-comment' };
52
+ emit(ch, 'line-comment', i); i++;
53
+ emit(next, 'line-comment', i); i++;
54
+ continue;
55
+ }
56
+ if (ch === '/' && next === '*') {
57
+ state = { in: 'block-comment', depth: 1 };
58
+ emit(ch, 'block-comment', i); i++;
59
+ emit(next, 'block-comment', i); i++;
60
+ continue;
61
+ }
62
+ const tag = dollarTagAt(text, i);
63
+ if (tag) {
64
+ state = { in: 'dollar', tag };
65
+ for (const c of tag) { emit(c, 'dollar', i); i++; }
66
+ continue;
67
+ }
68
+ if (ch === "'") {
69
+ // `E'...'` is PostgreSQL's backslash-escaping string literal — but the `E` only counts
70
+ // when it STANDS ALONE before the quote. Testing the single preceding character glued
71
+ // that rule onto the tail of any identifier or keyword ending in `e`, which in English
72
+ // is most of them: `date'2024-01-01'`, `store'x'`, `xe'y'`. A backslash inside such a
73
+ // literal then swallowed the closing quote, the lexer stayed "inside a string" past the
74
+ // real end, and `splitSqlStatements` stopped seeing the `;` boundaries after it — a
75
+ // multi-statement payload counted as ONE, which is exactly what db:query's one-statement
76
+ // rule exists to catch. So require the character BEFORE the `E`/`e` to be a
77
+ // non-identifier character, or absent (the `E` starts the text).
78
+ const prev = text[i - 1];
79
+ const beforePrev = text[i - 2];
80
+ const standaloneE = (prev === 'E' || prev === 'e')
81
+ && (beforePrev === undefined || !/[A-Za-z0-9_$]/.test(beforePrev));
82
+ state = { in: 'single', escaped: standaloneE };
83
+ emit(ch, 'single', i); i++;
84
+ continue;
85
+ }
86
+ if (ch === '"') {
87
+ state = { in: 'double' };
88
+ emit(ch, 'double', i); i++;
89
+ continue;
90
+ }
91
+ emit(ch, 'normal', i); i++;
92
+ continue;
93
+ }
94
+
95
+ if (state.in === 'line-comment') {
96
+ emit(ch, 'line-comment', i); i++;
97
+ if (ch === '\n') state = { in: 'normal' };
98
+ continue;
99
+ }
100
+
101
+ if (state.in === 'block-comment') {
102
+ if (ch === '/' && next === '*') {
103
+ state = { in: 'block-comment', depth: state.depth + 1 };
104
+ emit(ch, 'block-comment', i); i++;
105
+ emit(next, 'block-comment', i); i++;
106
+ continue;
107
+ }
108
+ if (ch === '*' && next === '/') {
109
+ const newDepth: number = state.depth - 1;
110
+ emit(ch, 'block-comment', i); i++;
111
+ emit(next, 'block-comment', i); i++;
112
+ state = newDepth === 0 ? { in: 'normal' } : { in: 'block-comment', depth: newDepth };
113
+ continue;
114
+ }
115
+ emit(ch, 'block-comment', i); i++;
116
+ continue;
117
+ }
118
+
119
+ if (state.in === 'single') {
120
+ if (state.escaped && ch === '\\') {
121
+ emit(ch, 'single', i); i++;
122
+ if (i < text.length) { emit(text[i], 'single', i); i++; }
123
+ continue;
124
+ }
125
+ if (ch === "'" && next === "'") {
126
+ emit(ch, 'single', i); i++;
127
+ emit(next, 'single', i); i++;
128
+ continue;
129
+ }
130
+ emit(ch, 'single', i); i++;
131
+ if (ch === "'") state = { in: 'normal' };
132
+ continue;
133
+ }
134
+
135
+ if (state.in === 'double') {
136
+ if (ch === '"' && next === '"') {
137
+ emit(ch, 'double', i); i++;
138
+ emit(next, 'double', i); i++;
139
+ continue;
140
+ }
141
+ emit(ch, 'double', i); i++;
142
+ if (ch === '"') state = { in: 'normal' };
143
+ continue;
144
+ }
145
+
146
+ // dollar
147
+ if (ch === '$') {
148
+ const tag = dollarTagAt(text, i);
149
+ if (tag === state.tag) {
150
+ for (const c of tag) { emit(c, 'dollar', i); i++; }
151
+ state = { in: 'normal' };
152
+ continue;
153
+ }
154
+ }
155
+ emit(ch, 'dollar', i); i++;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Split SQL text into statements on top-level semicolons. Statements come back trimmed.
161
+ *
162
+ * A fragment counts as a statement only if it carries something OTHER than whitespace and
163
+ * comments. `SELECT 1; -- done` is ONE statement, not two: the trailing comment accumulates
164
+ * into the next-statement buffer, and counting that buffer as a statement made an ordinary
165
+ * query with a trailing comment fail `db:query`'s "exactly one statement" rule. A fragment
166
+ * with no executable text cannot hide a write, so dropping it costs the guard nothing.
167
+ */
168
+ export function splitSqlStatements(text: string): string[] {
169
+ const statements: string[] = [];
170
+ let current = '';
171
+ const flush = (): void => {
172
+ const s = current.trim();
173
+ current = '';
174
+ // Comments are stripped for the emptiness TEST only — the statement itself is pushed
175
+ // verbatim, so the executor still runs exactly what the caller wrote.
176
+ if (s && stripSqlComments(s).trim()) statements.push(s);
177
+ };
178
+ lex(text, (ch, state) => {
179
+ if (state === 'normal' && ch === ';') {
180
+ flush();
181
+ return;
182
+ }
183
+ current += ch;
184
+ });
185
+ flush();
186
+ return statements;
187
+ }
188
+
189
+ /**
190
+ * Strip `--` and `/* *\/` comments from a single statement (line/block only — a dollar-quoted
191
+ * or string-literal body is left byte-for-byte since it is data, not SQL syntax). Everything
192
+ * else survives verbatim, including whitespace, so a stripped statement still starts with its
193
+ * real first keyword for a `^`-anchored check — comment removal, not normalization.
194
+ *
195
+ * Each comment run is replaced by ONE SPACE, never by nothing. PostgreSQL treats a comment as
196
+ * whitespace, so `INSERT/**\/INTO t VALUES (1)` is a plain INSERT to the engine. Deleting the
197
+ * comment outright produced `INSERTINTO t VALUES (1)`, which no longer matched a keyword check
198
+ * — the checked text and the executed text disagreed, and the write ran. One space keeps the
199
+ * two in agreement.
200
+ */
201
+ export function stripSqlComments(text: string): string {
202
+ let out = '';
203
+ let inComment = false;
204
+ lex(text, (ch, state) => {
205
+ const isComment = state === 'line-comment' || state === 'block-comment';
206
+ if (isComment) {
207
+ if (!inComment) {
208
+ out += ' ';
209
+ inComment = true;
210
+ }
211
+ return;
212
+ }
213
+ inComment = false;
214
+ out += ch;
215
+ });
216
+ return out;
217
+ }