|
| 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