| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as SqlClient from "effect/sql/SqlClient"; |
| 7 | |
| 8 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 9 | import { runSqliteState } from "./t3-sqlite-state.ts"; |
| 10 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 11 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 12 | |
| 13 | const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( |
| 14 | baseDir: string, |
| 15 | ) { |
| 16 | const fs = yield* FileSystem.FileSystem; |
| 17 | const path = yield* Path.Path; |
| 18 | const stateDir = path.join(baseDir, "userdata"); |
| 19 | const databasePath = path.join(stateDir, "statev2.sqlite"); |
| 20 | yield* fs.makeDirectory(stateDir, { recursive: true }); |
| 21 | yield* Effect.gen(function* () { |
| 22 | const sql = yield* SqlClient.SqlClient; |
| 23 | yield* sql`CREATE TABLE fixtures (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`; |
| 24 | yield* sql`INSERT INTO fixtures (id, label) VALUES (1, 'existing')`; |
| 25 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); |
| 26 | }); |
| 27 | |
| 28 | it.layer(NodeServices.layer)("t3-sqlite-state", (it) => { |
| 29 | it.effect("reports each invalid SQL source with a specific error", () => |
| 30 | Effect.gen(function* () { |
| 31 | const fs = yield* FileSystem.FileSystem; |
| 32 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-input-" }); |
| 33 | |
| 34 | const multipleSources = yield* runSqliteState({ |
| 35 | operation: "query", |
| 36 | baseDir, |
| 37 | sql: "SELECT 1", |
| 38 | file: "fixture.sql", |
| 39 | }).pipe(Effect.flip); |
| 40 | assert.equal(multipleSources._tag, "SqliteStateMultipleSqlSourcesError"); |
| 41 | assert.equal(multipleSources.message, "Provide only one of --sql or --file."); |
| 42 | |
| 43 | const missingSource = yield* runSqliteState({ operation: "query", baseDir }).pipe( |
| 44 | Effect.flip, |
| 45 | ); |
| 46 | assert.equal(missingSource._tag, "SqliteStateMissingSqlSourceError"); |
| 47 | assert.equal(missingSource.message, "Provide one of --sql or --file."); |
| 48 | |
| 49 | const emptySql = yield* runSqliteState({ |
| 50 | operation: "query", |
| 51 | baseDir, |
| 52 | sql: " ", |
| 53 | }).pipe(Effect.flip); |
| 54 | assert.equal(emptySql._tag, "SqliteStateEmptySqlError"); |
| 55 | assert.equal(emptySql.message, "SQL input is empty."); |
| 56 | }), |
| 57 | ); |
| 58 | |
| 59 | it.effect("queries an isolated database through Effect SQL", () => |
| 60 | Effect.gen(function* () { |
| 61 | const fs = yield* FileSystem.FileSystem; |
| 62 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-query-" }); |
| 63 | yield* createFixtureDatabase(baseDir); |
| 64 | |
| 65 | const result = yield* runSqliteState({ |
| 66 | operation: "query", |
| 67 | baseDir, |
| 68 | sql: "SELECT id, label FROM fixtures", |
| 69 | }); |
| 70 | |
| 71 | assert.equal(result.operation, "query"); |
| 72 | if (result.operation === "query") { |
| 73 | assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]); |
| 74 | } |
| 75 | }), |
| 76 | ); |
| 77 | |
| 78 | it.effect("enables checkpoint_fullfsync on its direct connection", () => |
| 79 | Effect.gen(function* () { |
| 80 | const fs = yield* FileSystem.FileSystem; |
| 81 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-fsync-" }); |
| 82 | yield* createFixtureDatabase(baseDir); |
| 83 | |
| 84 | const result = yield* runSqliteState({ |
| 85 | operation: "query", |
| 86 | baseDir, |
| 87 | sql: "PRAGMA checkpoint_fullfsync", |
| 88 | }); |
| 89 | |
| 90 | assert.equal(result.operation, "query"); |
| 91 | if (result.operation === "query") { |
| 92 | assert.deepStrictEqual(result.rows, [{ checkpoint_fullfsync: 1 }]); |
| 93 | } |
| 94 | }), |
| 95 | ); |
| 96 | |
| 97 | it.effect.skipIf(!symlinksSupported)( |
| 98 | "backs up isolated state before writes and refuses the shared home", |
| 99 | () => |
| 100 | Effect.gen(function* () { |
| 101 | const fs = yield* FileSystem.FileSystem; |
| 102 | const path = yield* Path.Path; |
| 103 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" }); |
| 104 | yield* createFixtureDatabase(baseDir); |
| 105 | |
| 106 | const mutation = yield* runSqliteState({ |
| 107 | operation: "exec", |
| 108 | baseDir, |
| 109 | sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')", |
| 110 | }); |
| 111 | assert.equal(mutation.operation, "exec"); |
| 112 | if (mutation.operation === "exec" && (yield* HostProcess.Platform) !== "win32") { |
| 113 | // NTFS has no POSIX mode bits to report. |
| 114 | assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600); |
| 115 | } |
| 116 | |
| 117 | const error = yield* runSqliteState( |
| 118 | { |
| 119 | operation: "exec", |
| 120 | baseDir, |
| 121 | sql: "DELETE FROM fixtures", |
| 122 | }, |
| 123 | { sharedHome: baseDir }, |
| 124 | ).pipe(Effect.flip); |
| 125 | assert.equal(error._tag, "SqliteStateSharedHomeMutationError"); |
| 126 | |
| 127 | const aliasParent = yield* fs.makeTempDirectoryScoped({ |
| 128 | prefix: "t3-sqlite-state-alias-", |
| 129 | }); |
| 130 | const aliasBaseDir = path.join(aliasParent, "shared-home-alias"); |
| 131 | yield* fs.symlink(baseDir, aliasBaseDir); |
| 132 | const aliasError = yield* runSqliteState( |
| 133 | { |
| 134 | operation: "exec", |
| 135 | baseDir: aliasBaseDir, |
| 136 | sql: "DELETE FROM fixtures", |
| 137 | }, |
| 138 | { sharedHome: baseDir }, |
| 139 | ).pipe(Effect.flip); |
| 140 | assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError"); |
| 141 | }), |
| 142 | ); |
| 143 | }); |