Skip to content

Commit 228a292

Browse files
committed
wip(service-analytics): declare the sqlDialect accept set + diagnose an out-of-contract answer
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
1 parent e758131 commit 228a292

3 files changed

Lines changed: 551 additions & 4 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16206] `AnalyticsServiceConfig.sqlDialect` — a PUBLIC host hook that was
5+
* typed as free `string` while only three spellings ever did anything.
6+
*
7+
* ## The defect
8+
*
9+
* `normalizeSqlDialect` accepts `'sqlite'` / `'postgres'` / `'mysql'` and reads
10+
* everything else as `'unknown'`. That set is `driver-sql`'s `SqlDialectName`
11+
* vocabulary and is the right one when the answer comes from
12+
* `SqlDriver.dialectName` — but the reader is `sqlDialectFor`, and its channel
13+
* is a HOST-supplied hook. A host that owns a SQLite datasource and answers the
14+
* spelling its own stack uses — knex's canonical `'sqlite3'`, which
15+
* `driver-sql` itself lists in `SQLITE_EMIT_CLIENTS` alongside
16+
* `'better-sqlite3'` — landed on the residue arm.
17+
*
18+
* ⭐ And nothing told it. `sqlDialectFor` is tiered "cannot answer, do not
19+
* block" by design, so **a wrong answer and no answer were the same answer**.
20+
* These pins exist to break exactly that identity, and no more than that.
21+
*
22+
* ## What was ruled, and what is therefore pinned here
23+
*
24+
* Option A: the declared return vocabulary is the three canonical names or
25+
* `undefined`, stated on the type and in the docblock; a NON-EMPTY answer
26+
* outside them is diagnosed once; `undefined` stays silent and legal. ⛔ The
27+
* accept set was NOT widened to knex's aliases (that was refused by name — a
28+
* second copy of `driver-sql`'s table is the drift this repo keeps paying for,
29+
* and #11756 shows an unrecognised spelling is sometimes deliberate), and ⛔ the
30+
* diagnostic was not skipped (leaving the host uninformed is the silent-
31+
* tolerance shape).
32+
*
33+
* ⇒ Both halves of the ruling's named pin are below, and the second is the
34+
* CONTROL that keeps the first honest: a suite that only asserted "a warning
35+
* appeared" would pass just as well for an implementation that shouts at every
36+
* host who wired nothing, which is the failure mode the tiering exists to
37+
* prevent.
38+
*
39+
* ## The `unknown` arm's rows, EXECUTED — not inherited
40+
*
41+
* The last block drives the case-EXACT family (`$contains` and its three
42+
* siblings) through a host answering `'sqlite3'`, on sql.js, and prints the row
43+
* ids it gets. The card carried that consequence as NOT MEASURED, read off
44+
* #15684's own measurement of the same arm rather than re-driven for this
45+
* population. It is measured here, with the canonical-spelling host as the
46+
* discriminating control — the two hosts differ in ONE character of one string
47+
* and in nothing else.
48+
*
49+
* ⚠️ Those assertions pin a WRONG row set on purpose. They are the finding, not
50+
* the contract: #15684's fold is live on this arm, and the day it is fixed
51+
* there this block must go red and be re-read, exactly as
52+
* `text-operator-case-exactness.test.ts` intends for its own "the defect, still
53+
* reachable" pin. ⛔ Do not "repair" them by loosening the expectation.
54+
*/
55+
56+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
57+
import type { Cube } from '@objectstack/spec/data';
58+
import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data';
59+
import type { AnalyticsQuery } from '@objectstack/spec/contracts';
60+
61+
import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js';
62+
import {
63+
ACCEPTED_SQL_DIALECTS,
64+
isUnrecognisedSqlDialectAnswer,
65+
normalizeSqlDialect,
66+
type AcceptedSqlDialect,
67+
} from '../text-match-sql.js';
68+
69+
/** The four operators #4706 Q2 = A rules case-SENSITIVE. */
70+
const CASE_EXACT_OPS = new Set(['$contains', '$notContains', '$startsWith', '$endsWith']);
71+
72+
/**
73+
* The shared table's rows that aim a case-EXACT operator at the TEXT column —
74+
* selected from `FILTER_TEXT_CASES` rather than restated, so this file drives
75+
* the same family the five drivers answer.
76+
*/
77+
const NAME_CASE_EXACT = FILTER_TEXT_CASES.filter(
78+
(c): c is Extract<typeof c, { expected: readonly string[] }> => {
79+
if (c.expectRejection === true) return false;
80+
const entries = Object.entries(c.filter as Record<string, unknown>);
81+
if (entries.length !== 1) return false;
82+
const [field, predicate] = entries[0];
83+
if (field !== 'name' || typeof predicate !== 'object' || predicate === null) return false;
84+
const op = Object.keys(predicate as Record<string, unknown>)[0];
85+
return CASE_EXACT_OPS.has(op);
86+
},
87+
);
88+
89+
const CUBE: Cube = {
90+
name: 'texts',
91+
title: 'Texts',
92+
sql: 'rows',
93+
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
94+
dimensions: {
95+
id: { name: 'id', label: 'Id', type: 'string', sql: 'id' },
96+
name: { name: 'name', label: 'Name', type: 'string', sql: 'name' },
97+
},
98+
public: false,
99+
} as unknown as Cube;
100+
101+
/** A second cube over the SAME table, so "one answer, many objects" is drivable. */
102+
const OTHER_CUBE: Cube = {
103+
...(CUBE as unknown as Record<string, unknown>),
104+
name: 'other_texts',
105+
} as unknown as Cube;
106+
107+
const query = (where: unknown, cube = 'texts'): AnalyticsQuery =>
108+
({ cube, measures: ['total'], dimensions: ['id'], timezone: 'UTC', where }) as AnalyticsQuery;
109+
110+
const makeLogger = () => ({
111+
info: vi.fn(),
112+
debug: vi.fn(),
113+
warn: vi.fn(),
114+
error: vi.fn(),
115+
child: vi.fn().mockReturnThis(),
116+
});
117+
118+
type TestLogger = ReturnType<typeof makeLogger>;
119+
120+
/**
121+
* A service composed the way a direct embedder composes one — the population
122+
* this card is about. `answer` is deliberately typed `string | undefined`, not
123+
* {@link AcceptedSqlDialect}: the declaration says what a host is ASKED for, and
124+
* every interesting case here is a host answering something else.
125+
*/
126+
const serviceAnswering = (
127+
answer: string | undefined | (() => string | undefined),
128+
): { service: AnalyticsService; logger: TestLogger } => {
129+
const logger = makeLogger();
130+
const hook = typeof answer === 'function' ? answer : () => answer;
131+
const config = {
132+
logger: logger as unknown as AnalyticsServiceConfig['logger'],
133+
cubes: [CUBE, OTHER_CUBE],
134+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
135+
sqlDialect: hook as unknown as AnalyticsServiceConfig['sqlDialect'],
136+
} satisfies AnalyticsServiceConfig;
137+
return { service: new AnalyticsService(config), logger };
138+
};
139+
140+
/** A service that wired NO hook at all — the legal, silent composition. */
141+
const serviceAnsweringNothing = (): { service: AnalyticsService; logger: TestLogger } => {
142+
const logger = makeLogger();
143+
return {
144+
service: new AnalyticsService({
145+
logger: logger as unknown as AnalyticsServiceConfig['logger'],
146+
cubes: [CUBE, OTHER_CUBE],
147+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
148+
}),
149+
logger,
150+
};
151+
};
152+
153+
/** Only the dialect diagnostic — the constructor's own `info` is not it. */
154+
const dialectWarnings = (logger: TestLogger): string[] =>
155+
logger.warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('sqlDialect hook answered'));
156+
157+
describe('[#16206] the declared vocabulary', () => {
158+
it('is the three canonical names, and the type and the runtime set are ONE source', () => {
159+
expect([...ACCEPTED_SQL_DIALECTS]).toEqual(['sqlite', 'postgres', 'mysql']);
160+
// Every declared name is accepted at runtime…
161+
for (const d of ACCEPTED_SQL_DIALECTS) expect(normalizeSqlDialect(d), d).toBe(d);
162+
// …and the type is the same list, checked by the compiler rather than by eye.
163+
const declared: readonly AcceptedSqlDialect[] = ACCEPTED_SQL_DIALECTS;
164+
expect(declared.length).toBe(3);
165+
// ⛔ `'unknown'` is the residue arm, never something a host may answer.
166+
expect((ACCEPTED_SQL_DIALECTS as readonly string[]).includes('unknown')).toBe(false);
167+
expect(normalizeSqlDialect('unknown')).toBe('unknown');
168+
});
169+
170+
it('⛔ was NOT widened to driver-sql\'s knex aliases — option B, refused by name', () => {
171+
// These are exactly the spellings `SqlDriver.SQLITE_EMIT_CLIENTS`,
172+
// `POSTGRES_EMIT_CLIENTS` and `MYSQL_EMIT_CLIENTS` recognise and this file
173+
// deliberately does not. A widening here is the refused option, not a fix.
174+
for (const alias of ['sqlite3', 'better-sqlite3', 'pg', 'postgresql', 'pgnative', 'mysql2', 'mariadb']) {
175+
expect(normalizeSqlDialect(alias), alias).toBe('unknown');
176+
}
177+
});
178+
179+
it('separates a non-answer from a wrong answer — the identity the defect rested on', () => {
180+
// A wrong answer: something was said, and it is outside the accept set.
181+
for (const wrong of ['sqlite3', 'better-sqlite3', 'SQLite', 'mssql', ' sqlite']) {
182+
expect(isUnrecognisedSqlDialectAnswer(wrong), wrong).toBe(true);
183+
}
184+
// ⛔ A non-answer is NOT a wrong answer. The hook is optional.
185+
for (const silent of [undefined, null, '']) {
186+
expect(isUnrecognisedSqlDialectAnswer(silent), String(silent)).toBe(false);
187+
}
188+
// …and neither is a name that IS accepted.
189+
for (const ok of ACCEPTED_SQL_DIALECTS) expect(isUnrecognisedSqlDialectAnswer(ok), ok).toBe(false);
190+
});
191+
});
192+
193+
describe('[#16206] the ruling\'s named pin — both halves', () => {
194+
it('a host answering knex\'s `sqlite3` is read as `unknown` AND is told so, once', async () => {
195+
const { service, logger } = serviceAnswering('sqlite3');
196+
const out = await service.generateSql(query({ name: { $contains: 'acme' } }));
197+
198+
// Half one, the behaviour: still the residue arm. ⛔ The answer is NOT
199+
// accepted — the diagnostic informs, it does not widen.
200+
expect(out.sql).toContain('LIKE');
201+
expect(out.sql).not.toMatch(/GLOB/);
202+
203+
// Half one, the diagnostic: exactly one line, naming all three things the
204+
// ruling requires — the object, the answer, and the accepted set.
205+
const warnings = dialectWarnings(logger);
206+
expect(warnings).toHaveLength(1);
207+
expect(warnings[0]).toContain('"sqlite3"');
208+
expect(warnings[0]).toContain('"texts"');
209+
for (const accepted of ACCEPTED_SQL_DIALECTS) expect(warnings[0], accepted).toContain(accepted);
210+
});
211+
212+
it('⛔ `undefined` says nothing — the optional hook stays optional', async () => {
213+
// The control that keeps the pin above honest. An implementation that made
214+
// "no answer" loud would pass the first test and fail this one.
215+
const { service: wired, logger: wiredLog } = serviceAnswering(undefined);
216+
await wired.generateSql(query({ name: { $contains: 'acme' } }));
217+
expect(dialectWarnings(wiredLog)).toEqual([]);
218+
219+
// …and so does a host that wired no hook at all.
220+
const { service: bare, logger: bareLog } = serviceAnsweringNothing();
221+
await bare.generateSql(query({ name: { $contains: 'acme' } }));
222+
expect(dialectWarnings(bareLog)).toEqual([]);
223+
224+
// An empty string is a non-answer too: "non-empty" is the ruled trigger.
225+
const { service: empty, logger: emptyLog } = serviceAnswering('');
226+
await empty.generateSql(query({ name: { $contains: 'acme' } }));
227+
expect(dialectWarnings(emptyLog)).toEqual([]);
228+
});
229+
230+
it('says nothing to a host that answers correctly', async () => {
231+
for (const accepted of ACCEPTED_SQL_DIALECTS) {
232+
const { service, logger } = serviceAnswering(accepted);
233+
await service.generateSql(query({ name: { $contains: 'acme' } }));
234+
expect(dialectWarnings(logger), accepted).toEqual([]);
235+
}
236+
});
237+
});
238+
239+
describe('[#16206] "once" is keyed on the failure\'s identity, and is bounded', () => {
240+
it('one misspelling reaching many objects and many queries is ONE line', async () => {
241+
const { service, logger } = serviceAnswering('sqlite3');
242+
for (let i = 0; i < 25; i++) {
243+
await service.generateSql(query({ name: { $contains: 'acme' } }, 'texts'));
244+
await service.generateSql(query({ name: { $startsWith: 'ACME' } }, 'other_texts'));
245+
}
246+
const warnings = dialectWarnings(logger);
247+
expect(warnings).toHaveLength(1);
248+
// The FIRST object to elicit it is the one named — a concrete place to look,
249+
// not a count that grows with the object registry.
250+
expect(warnings[0]).toContain('"texts"');
251+
});
252+
253+
it('a SECOND, DIFFERENT wrong answer is a second failure and gets its own line', async () => {
254+
let answer = 'sqlite3';
255+
const { service, logger } = serviceAnswering(() => answer);
256+
await service.generateSql(query({ name: { $contains: 'acme' } }));
257+
answer = 'better-sqlite3';
258+
await service.generateSql(query({ name: { $contains: 'acme' } }));
259+
answer = 'sqlite3';
260+
await service.generateSql(query({ name: { $contains: 'acme' } }));
261+
262+
const warnings = dialectWarnings(logger);
263+
expect(warnings).toHaveLength(2);
264+
expect(warnings[0]).toContain('"sqlite3"');
265+
expect(warnings[1]).toContain('"better-sqlite3"');
266+
});
267+
268+
it('the line count does not move with TRAFFIC — 4x the queries, the same one line', async () => {
269+
const drive = async (laps: number): Promise<number> => {
270+
const { service, logger } = serviceAnswering('sqlite3');
271+
for (let i = 0; i < laps; i++) {
272+
await service.generateSql(query({ name: { $contains: 'acme' } }, 'texts'));
273+
await service.generateSql(query({ name: { $notContains: 'acme' } }, 'other_texts'));
274+
}
275+
return dialectWarnings(logger).length;
276+
};
277+
const oneX = await drive(50);
278+
const fourX = await drive(200);
279+
expect(oneX).toBe(1);
280+
expect(fourX).toBe(oneX);
281+
});
282+
283+
it('each service instance carries its OWN key set — no module-global residue', async () => {
284+
// A process-wide key would make the second host's identical
285+
// misconfiguration invisible, which is the shape #15166 was filed against.
286+
const first = serviceAnswering('sqlite3');
287+
await first.service.generateSql(query({ name: { $contains: 'acme' } }));
288+
const second = serviceAnswering('sqlite3');
289+
await second.service.generateSql(query({ name: { $contains: 'acme' } }));
290+
expect(dialectWarnings(first.logger)).toHaveLength(1);
291+
expect(dialectWarnings(second.logger)).toHaveLength(1);
292+
});
293+
});
294+
295+
/**
296+
* ⚠️ The measurement the ruling made a precondition of landing: the case-EXACT
297+
* family, EXECUTED on SQLite, through a host answering `'sqlite3'`.
298+
*/
299+
describe('[#16206] the `unknown` arm\'s ROWS for a `sqlite3`-answering host, on a real SQLite engine', () => {
300+
let db: any;
301+
let sqlite3Host: AnalyticsService;
302+
let sqliteHost: AnalyticsService;
303+
304+
/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
305+
const locateWasm = async (): Promise<((file: string) => string) | undefined> => {
306+
try {
307+
const { createRequire } = await import('node:module');
308+
const require = createRequire(import.meta.url);
309+
const pkgJsonPath = require.resolve('sql.js/package.json');
310+
const { dirname, join } = await import('node:path');
311+
return (file: string) => join(dirname(pkgJsonPath), 'dist', file);
312+
} catch {
313+
return undefined;
314+
}
315+
};
316+
317+
const run = (sql: string, params: unknown[]): string[] => {
318+
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
319+
stmt.bind(params as any[]);
320+
const rows: Record<string, unknown>[] = [];
321+
while (stmt.step()) rows.push(stmt.getAsObject());
322+
stmt.free();
323+
return rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b));
324+
};
325+
326+
beforeAll(async () => {
327+
const mod: any = await import('sql.js');
328+
const initSqlJs = mod.default ?? mod;
329+
const locateFile = await locateWasm();
330+
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);
331+
db = new SQL.Database();
332+
db.run(`CREATE TABLE "rows" ("id" TEXT PRIMARY KEY, "name" TEXT);`);
333+
const insert = db.prepare(`INSERT INTO "rows" ("id","name") VALUES (?,?)`);
334+
for (const r of FILTER_TEXT_ROWS) insert.run([r.id, r.name]);
335+
insert.free();
336+
337+
// The two hosts differ in ONE character of ONE string. Everything else —
338+
// cubes, capabilities, engine, fixture — is identical, which is what makes
339+
// the row difference below attributable to the spelling.
340+
sqlite3Host = serviceAnswering('sqlite3').service;
341+
sqliteHost = serviceAnswering('sqlite').service;
342+
});
343+
344+
afterAll(() => {
345+
db?.close();
346+
});
347+
348+
const executedIds = async (where: unknown, service: AnalyticsService): Promise<string[]> => {
349+
const { sql, params } = await service.generateSql(query(where));
350+
return run(sql, params);
351+
};
352+
353+
it('the rig discriminates: the canonical-spelling CONTROL answers the shared table exactly', async () => {
354+
// Same query, same engine, same rows — the only host that is heard.
355+
expect(run('SELECT "id" FROM "rows"', [])).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']);
356+
expect(NAME_CASE_EXACT.length).toBeGreaterThan(0);
357+
for (const c of NAME_CASE_EXACT) {
358+
expect(await executedIds(c.filter, sqliteHost), `control · ${c.name}`).toEqual([...c.expected]);
359+
}
360+
});
361+
362+
it('⭐ the `sqlite3`-answering host gets WRONG ROWS — not merely slower ones', async () => {
363+
// ⚠️ This is the finding, not the contract. #15684's ASCII fold is live on
364+
// the `unknown` arm, and this host reaches that arm.
365+
const wrong: { case: string; expected: string[]; measured: string[] }[] = [];
366+
for (const c of NAME_CASE_EXACT) {
367+
const measured = await executedIds(c.filter, sqlite3Host);
368+
if (JSON.stringify(measured) !== JSON.stringify([...c.expected])) {
369+
wrong.push({ case: c.name, expected: [...c.expected], measured });
370+
}
371+
}
372+
// At least the two case-folding rows of the shared table come back wrong.
373+
expect(wrong.length).toBeGreaterThan(0);
374+
375+
// Named, so the report reads the rows rather than a count.
376+
expect(await executedIds({ name: { $contains: 'acme' } }, sqlite3Host)).toEqual(['1', '2']);
377+
expect(await executedIds({ name: { $contains: 'acme' } }, sqliteHost)).toEqual(['2']);
378+
expect(await executedIds({ name: { $startsWith: 'ACME' } }, sqlite3Host)).toEqual(['1', '2']);
379+
expect(await executedIds({ name: { $startsWith: 'ACME' } }, sqliteHost)).toEqual(['1']);
380+
expect(await executedIds({ name: { $endsWith: 'corp' } }, sqlite3Host)).toEqual(['1', '2']);
381+
expect(await executedIds({ name: { $endsWith: 'corp' } }, sqliteHost)).toEqual(['2']);
382+
// Negation does not widen it back: the over-match becomes an under-match.
383+
expect(await executedIds({ name: { $notContains: 'acme' } }, sqlite3Host))
384+
.toEqual(['3', '4', '5', '6', '7', '8', '9']);
385+
expect(await executedIds({ name: { $notContains: 'acme' } }, sqliteHost))
386+
.toEqual(['1', '3', '4', '5', '6', '7', '8', '9']);
387+
});
388+
});

0 commit comments

Comments
 (0)