| ... | ... | @@ -131,6 +131,102 @@ layer("NodeSqliteClient", (it) => { |
| 131 | 131 | ); |
| 132 | 132 | }); |
| 133 | 133 | |
| 134 | const largeText = "x".repeat(64 * 1024 + 1); |
| 135 | |
| 136 | it.effect("keeps large text and blob parameters off cached statements", () => |
| 137 | Effect.gen(function* () { |
| 138 | const sql = yield* SqlClient.SqlClient; |
| 139 | const statements: Array<NodeSqlite.StatementSync> = []; |
| 140 | const all = NodeSqlite.StatementSync.prototype.all; |
| 141 | yield* Effect.acquireRelease( |
| 142 | Effect.sync(() => |
| 143 | vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function ( |
| 144 | this: NodeSqlite.StatementSync, |
| 145 | ...params |
| 146 | ) { |
| 147 | statements.push(this); |
| 148 | return all.apply(this, params); |
| 149 | }), |
| 150 | ), |
| 151 | (spy) => Effect.sync(() => spy.mockRestore()), |
| 152 | ); |
| 153 | |
| 154 | const small = "small"; |
| 155 | const queries = [ |
| 156 | (value: string | Uint8Array | ArrayBuffer) => sql`SELECT length(${value}) AS size`, |
| 157 | (value: string | Uint8Array | ArrayBuffer) => |
| 158 | sql.unsafe("SELECT length($value) AS size", [{ $value: value }]), |
| 159 | (value: string | Uint8Array | ArrayBuffer) => |
| 160 | sql.unsafe("SELECT length($value) AS callable", [ |
| 161 | Object.assign(() => {}, { $value: value }), |
| 162 | ]), |
| 163 | ]; |
| 164 | const cached = new Set<NodeSqlite.StatementSync>(); |
| 165 | const large = new Set<NodeSqlite.StatementSync>(); |
| 166 | const largeBlobs = [new Uint8Array(64 * 1024 + 1), new ArrayBuffer(64 * 1024 + 1)]; |
| 167 | for (const value of [small, largeText, ...largeBlobs, small]) { |
| 168 | const expected = typeof value === "string" ? value.length : value.byteLength; |
| 169 | for (const query of queries) { |
| 170 | assert.deepEqual((yield* query(value)).map(Object.values), [[expected]]); |
| 171 | assert.deepEqual(yield* query(value).values, [[expected]]); |
| 172 | for (const statement of statements.splice(0)) { |
| 173 | (value === small ? cached : large).add(statement); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // Small parameters keep reusing the one cached statement for each query. |
| 179 | assert.equal(cached.size, queries.length); |
| 180 | // Each large execution gets a statement that nothing holds on to afterwards. |
| 181 | assert.equal(large.size, queries.length * 6); |
| 182 | assert.isTrue(cached.isDisjointFrom(large)); |
| 183 | }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))), |
| 184 | ); |
| 185 | |
| 186 | it.effect("reads integers on the values path as the caller asked, cached or not", () => |
| 187 | Effect.gen(function* () { |
| 188 | const sql = yield* SqlClient.SqlClient; |
| 189 | for (const value of ["small", largeText]) { |
| 190 | const values = sql`SELECT 1 AS id, length(${value}) AS size`.values; |
| 191 | assert.deepEqual(yield* values.pipe(Effect.provideService(SqlClient.SafeIntegers, true)), [ |
| 192 | [1n, BigInt(value.length)], |
| 193 | ]); |
| 194 | assert.deepEqual(yield* values, [[1, value.length]]); |
| 195 | } |
| 196 | }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))), |
| 197 | ); |
| 198 | |
| 199 | it.effect("leaves a parameter it cannot measure for the statement to handle", () => |
| 200 | Effect.gen(function* () { |
| 201 | const sql = yield* SqlClient.SqlClient; |
| 202 | const detached = new DataView(new ArrayBuffer(8)); |
| 203 | structuredClone(detached.buffer, { transfer: [detached.buffer] }); |
| 204 | // What a detached view binds as varies by Node version; match the statement itself. |
| 205 | const native = new NodeSqlite.DatabaseSync(":memory:"); |
| 206 | const expected = native.prepare("SELECT length(?) AS size").all(detached); |
| 207 | native.close(); |
| 208 | assert.deepEqual(yield* sql`SELECT length(${detached}) AS size`, expected); |
| 209 | |
| 210 | const throwing = { |
| 211 | get $value(): string { |
| 212 | throw new Error("unreadable"); |
| 213 | }, |
| 214 | }; |
| 215 | const error = yield* sql.unsafe("SELECT length($value) AS size", [throwing]).pipe(Effect.flip); |
| 216 | assert.equal(error.reason.operation, "execute"); |
| 217 | |
| 218 | // Only the statement reads a getter, so it runs once as it would natively. |
| 219 | let reads = 0; |
| 220 | const counting = { |
| 221 | get $value(): number { |
| 222 | return ++reads; |
| 223 | }, |
| 224 | }; |
| 225 | assert.deepEqual(yield* sql.unsafe("SELECT $value AS value", [counting]), [{ value: 1 }]); |
| 226 | assert.equal(reads, 1); |
| 227 | }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))), |
| 228 | ); |
| 229 | |
| 134 | 230 | const makeTempDatabase = Effect.gen(function* () { |
| 135 | 231 | const fs = yield* FileSystem.FileSystem; |
| 136 | 232 | const path = yield* Path.Path; |
| ... | ... | @@ -311,6 +407,38 @@ it.effect("keeps read-only work inside a write transaction on the writer", () => |
| 311 | 407 | ), |
| 312 | 408 | ); |
| 313 | 409 | |
| 410 | it.effect("keeps large parameters off the reader worker's cached statements", () => |
| 411 | withReaderClient((sql) => |
| 412 | Effect.gen(function* () { |
| 413 | const read = (value: string) => |
| 414 | sql<{ size: number }>`SELECT length(${value}) AS size`.pipe( |
| 415 | Effect.map((rows) => rows[0]?.size), |
| 416 | ); |
| 417 | // Start the worker so its requests can be recorded. |
| 418 | yield* countEntries(sql).pipe(SqliteClient.readOnly); |
| 419 | const worker = startedWorkers.at(-1)!; |
| 420 | const cached: Array<boolean> = []; |
| 421 | const postMessage = worker.postMessage; |
| 422 | yield* Effect.acquireRelease( |
| 423 | Effect.sync(() => |
| 424 | vi.spyOn(worker, "postMessage").mockImplementation(function ( |
| 425 | this: NodeWorkerThreads.Worker, |
| 426 | message: { readonly cached: boolean }, |
| 427 | ) { |
| 428 | cached.push(message.cached); |
| 429 | return postMessage.call(this, message); |
| 430 | }), |
| 431 | ), |
| 432 | (spy) => Effect.sync(() => spy.mockRestore()), |
| 433 | ); |
| 434 | |
| 435 | assert.equal(yield* read("small").pipe(SqliteClient.readOnly), 5); |
| 436 | assert.equal(yield* read(largeText).pipe(SqliteClient.readOnly), largeText.length); |
| 437 | assert.deepEqual(cached, [true, false]); |
| 438 | }).pipe(Effect.scoped), |
| 439 | ), |
| 440 | ); |
| 441 | |
| 314 | 442 | it.effect("fails a read-only transaction whose reader stops, then starts a new reader", () => |
| 315 | 443 | withReaderClient((sql) => |
| 316 | 444 | Effect.gen(function* () { |