@awsmbiz/inbis-db 0.0.0-stage → 0.1.0
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/README.md +95 -2
- package/dist/cli.mjs +485 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -3
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @awsmbiz/inbis-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
D1 + Drizzle helpers. Peer dependency: `drizzle-orm` ^0.45.4. No tenant columns and no
|
|
4
|
+
generic repository: write queries with Drizzle in your own repository layer.
|
|
5
|
+
|
|
6
|
+
## Database
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createDatabase } from "@awsmbiz/inbis-db";
|
|
10
|
+
import * as schema from "./schema";
|
|
11
|
+
|
|
12
|
+
const db = createDatabase(env.DB, { schema }); // Drizzle D1 instance; db.query.* enabled by schema
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Create it per request from the binding you were given. The options (`CreateDatabaseOptions`)
|
|
16
|
+
take the Drizzle `schema`.
|
|
17
|
+
|
|
18
|
+
## Columns
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
22
|
+
import { idColumn, timestampColumns } from "@awsmbiz/inbis-db";
|
|
23
|
+
|
|
24
|
+
export const widgets = sqliteTable("widgets", {
|
|
25
|
+
id: idColumn(), // TEXT PRIMARY KEY, UUIDv7 generated on insert
|
|
26
|
+
name: text("name").notNull(),
|
|
27
|
+
...timestampColumns(), // created_at / updated_at, epoch ms, as Date
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Batch (atomic writes)
|
|
32
|
+
|
|
33
|
+
D1 has no interactive transactions. `batch(db, [stmt, ...])` runs the statements
|
|
34
|
+
all-or-nothing in one round trip and returns their typed results in order. An empty
|
|
35
|
+
list returns `[]`. A unique-constraint violation becomes a `409 CONFLICT` `AppError`;
|
|
36
|
+
other errors are rethrown unchanged.
|
|
37
|
+
|
|
38
|
+
## Keyset pagination
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { gt } from "drizzle-orm";
|
|
42
|
+
import { parsePageRequest, toPage } from "@awsmbiz/inbis-db";
|
|
43
|
+
|
|
44
|
+
const page = parsePageRequest({ limit: query.limit, cursor: query.cursor }, { defaultLimit: 20, maxLimit: 100 });
|
|
45
|
+
const rows = await db.select().from(widgets)
|
|
46
|
+
.where(page.after === undefined ? undefined : gt(widgets.id, page.after))
|
|
47
|
+
.orderBy(widgets.id)
|
|
48
|
+
.limit(page.fetchLimit);
|
|
49
|
+
return toPage(rows, page, (w) => w.id); // { items, nextCursor: string | null }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Cursors are opaque. An invalid cursor gives `400 INVALID_CURSOR`, and an invalid limit
|
|
53
|
+
(not a positive integer) gives `422 VALIDATION_FAILED`. Limits above `maxLimit` are clamped.
|
|
54
|
+
|
|
55
|
+
## Migrations: the `inbis-db` CLI
|
|
56
|
+
|
|
57
|
+
Platform packages that own tables ship their migrations. Each migration source is a D1
|
|
58
|
+
binding to the app's one database, with its own `migrations_dir` and `migrations_table`
|
|
59
|
+
(ADR-4, `docs/adr/0004-package-owned-migrations.md`). `inbis-db` is a Node bin that drives
|
|
60
|
+
the app's `wrangler`. It keeps no history of its own.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
inbis-db migrate [--local|--remote] [--env <env>] [--config <path>] [--persist-to <dir>]
|
|
64
|
+
inbis-db status [same options]
|
|
65
|
+
inbis-db lock <dir>
|
|
66
|
+
inbis-db check-lock <dir> [--base <git-ref>]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **`migrate`** runs `wrangler d1 migrations apply <BINDING>` for each source of each
|
|
70
|
+
database, in `wrangler.jsonc` declaration order (platform bindings first, then the app). It
|
|
71
|
+
stops at the first failure, then prints the status table. It refuses to start if a file
|
|
72
|
+
locked in a source's `migrations.lock.json` has changed (checksum drift).
|
|
73
|
+
- **`status`** prints, per source, every migration as `applied` or `pending`, plus any applied
|
|
74
|
+
migration whose file is missing:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Migration status for D1 database "identity-playground-dev" (local):
|
|
78
|
+
binding table migration status
|
|
79
|
+
AUTH_DB _inbis_auth_migrations 0001_auth_initial.sql applied
|
|
80
|
+
DB d1_migrations 0001_app_profile.sql pending
|
|
81
|
+
1 applied, 1 pending
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **Timeout:** each wrangler run may take 5 minutes (`INBIS_DB_WRANGLER_TIMEOUT_MS` to
|
|
85
|
+
change it); a run past it is stopped and reported with its output so far (exit code 124),
|
|
86
|
+
so a hung wrangler cannot hang CI. Reading the status is one `wrangler d1 execute`
|
|
87
|
+
(two when a migrations table does not exist yet).
|
|
88
|
+
- **Layout checks** (both commands): a binding may not be named like its database; bindings of
|
|
89
|
+
one `database_name` must resolve to the same database (share `database_id`); sources may not
|
|
90
|
+
share a migrations table or directory; `migrations_pattern` is not supported (use flat `*.sql`).
|
|
91
|
+
- **`lock`** adds sha256 checksums for new `*.sql` files to `<dir>/migrations.lock.json` and
|
|
92
|
+
refuses if a locked file changed. **`check-lock`** fails on drift, deleted or unlocked files.
|
|
93
|
+
With `--base`, it also fails if any entry locked at that git ref (the released migrations)
|
|
94
|
+
changed, even when the lock was edited too.
|
|
95
|
+
|
|
96
|
+
Requires `wrangler` (optional peer dependency, ^4.156.0) in the app.
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
// cli/src/errors.ts
|
|
10
|
+
/** An expected failure: printed without a stack trace, exit code 1. */
|
|
11
|
+
var CliError = class extends Error {
|
|
12
|
+
name = "CliError";
|
|
13
|
+
};
|
|
14
|
+
// cli/src/sources.ts
|
|
15
|
+
const DEFAULT_MIGRATIONS_TABLE = "d1_migrations";
|
|
16
|
+
/** The database a binding resolves to, mirroring wrangler (local: preview id, then id, then binding). */
|
|
17
|
+
function databaseKey(entry, target) {
|
|
18
|
+
return target === "local" ? entry.preview_database_id ?? entry.database_id ?? entry.binding : entry.database_id ?? entry.binding;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Groups the config's D1 bindings by physical database, keeping declaration
|
|
22
|
+
* order (declare platform bindings before the app's). Rejects layouts where
|
|
23
|
+
* histories would collide or bindings silently miss each other (ADR-4).
|
|
24
|
+
*/
|
|
25
|
+
function databaseSources(config, target) {
|
|
26
|
+
const base = dirname(config.configPath ?? ".");
|
|
27
|
+
const groups = /* @__PURE__ */ new Map();
|
|
28
|
+
const keyByName = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const entry of config.d1_databases) {
|
|
30
|
+
const name = entry.database_name ?? entry.binding;
|
|
31
|
+
if (entry.binding === entry.database_name) throw new CliError(`D1 binding "${entry.binding}" has the same name as its database. Rename the binding: commands target bindings, and a database name always selects the first binding.`);
|
|
32
|
+
if (entry.migrations_pattern !== void 0) throw new CliError(`D1 binding "${entry.binding}" sets migrations_pattern, which inbis-db does not support. Use flat *.sql files in migrations_dir.`);
|
|
33
|
+
const key = databaseKey(entry, target);
|
|
34
|
+
const knownKey = keyByName.get(name);
|
|
35
|
+
if (knownKey !== void 0 && knownKey !== key) throw new CliError(`D1 bindings for database "${name}" resolve to different ${target} databases. Give every binding of one database the same database_id.`);
|
|
36
|
+
keyByName.set(name, key);
|
|
37
|
+
const group = groups.get(key) ?? {
|
|
38
|
+
database: name,
|
|
39
|
+
sources: []
|
|
40
|
+
};
|
|
41
|
+
groups.set(key, group);
|
|
42
|
+
const source = {
|
|
43
|
+
binding: entry.binding,
|
|
44
|
+
table: entry.migrations_table ?? DEFAULT_MIGRATIONS_TABLE,
|
|
45
|
+
dir: resolve(base, entry.migrations_dir ?? "migrations")
|
|
46
|
+
};
|
|
47
|
+
for (const other of group.sources) {
|
|
48
|
+
if (other.table === source.table) throw new CliError(`D1 bindings "${other.binding}" and "${source.binding}" share the migrations table "${source.table}". Each migration source needs its own migrations_table.`);
|
|
49
|
+
if (other.dir === source.dir) throw new CliError(`D1 bindings "${other.binding}" and "${source.binding}" share the migrations directory ${source.dir}.`);
|
|
50
|
+
}
|
|
51
|
+
group.sources.push(source);
|
|
52
|
+
}
|
|
53
|
+
return [...groups.values()];
|
|
54
|
+
}
|
|
55
|
+
/** Migration file names in a source, in apply order (the files wrangler applies). */
|
|
56
|
+
function migrationFiles(source) {
|
|
57
|
+
if (!existsSync(source.dir)) return [];
|
|
58
|
+
return readdirSync(source.dir, { withFileTypes: true }).filter((entry) => entry.isFile() && !entry.name.startsWith(".") && entry.name.endsWith(".sql")).map((entry) => entry.name).sort();
|
|
59
|
+
}
|
|
60
|
+
// cli/src/lock.ts
|
|
61
|
+
const LOCK_FILE = "migrations.lock.json";
|
|
62
|
+
/** sha256 of a migration file; line endings are normalised so checkouts on any OS agree. */
|
|
63
|
+
function checksum(contents) {
|
|
64
|
+
return `sha256:${createHash("sha256").update(contents.replaceAll("\r\n", "\n")).digest("hex")}`;
|
|
65
|
+
}
|
|
66
|
+
function readLock(dir) {
|
|
67
|
+
const path = join(dir, LOCK_FILE);
|
|
68
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : void 0;
|
|
69
|
+
}
|
|
70
|
+
/** Problems with locked files: changed (drift) or deleted. */
|
|
71
|
+
function lockDrift(dir, lock) {
|
|
72
|
+
return Object.entries(lock).flatMap(([name, expected]) => {
|
|
73
|
+
const path = join(dir, name);
|
|
74
|
+
if (!existsSync(path)) return [`${name} is locked but missing`];
|
|
75
|
+
return checksum(readFileSync(path, "utf8")) === expected ? [] : [`${name} changed after it was locked (checksum drift)`];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Every problem `check-lock` reports: drift, deleted files and files missing from the lock. */
|
|
79
|
+
function lockProblems(dir, lock) {
|
|
80
|
+
const unlocked = migrationFiles({
|
|
81
|
+
binding: "",
|
|
82
|
+
table: "",
|
|
83
|
+
dir
|
|
84
|
+
}).filter((name) => !(name in lock)).map((name) => `${name} is not in ${LOCK_FILE}; run inbis-db lock`);
|
|
85
|
+
return [...lockDrift(dir, lock), ...unlocked];
|
|
86
|
+
}
|
|
87
|
+
/** Adds checksums for new migration files. Refuses if a locked file has changed. */
|
|
88
|
+
function updateLock(dir) {
|
|
89
|
+
const lock = readLock(dir) ?? {};
|
|
90
|
+
const drift = lockDrift(dir, lock);
|
|
91
|
+
if (drift.length > 0) throw new CliError(`Cannot update ${LOCK_FILE}:\n ${drift.join("\n ")}`);
|
|
92
|
+
const added = migrationFiles({
|
|
93
|
+
binding: "",
|
|
94
|
+
table: "",
|
|
95
|
+
dir
|
|
96
|
+
}).filter((name) => !(name in lock));
|
|
97
|
+
for (const name of added) lock[name] = checksum(readFileSync(join(dir, name), "utf8"));
|
|
98
|
+
const sorted = Object.fromEntries(Object.entries(lock).sort(([a], [b]) => a.localeCompare(b)));
|
|
99
|
+
writeFileSync(join(dir, LOCK_FILE), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
100
|
+
return { added };
|
|
101
|
+
}
|
|
102
|
+
function git(dir, args) {
|
|
103
|
+
return execFileSync("git", args, {
|
|
104
|
+
cwd: dir,
|
|
105
|
+
encoding: "utf8",
|
|
106
|
+
stdio: [
|
|
107
|
+
"ignore",
|
|
108
|
+
"pipe",
|
|
109
|
+
"pipe"
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Released-migration guard: every entry in the lock at `baseRef` (e.g. the
|
|
115
|
+
* default branch) must still be present and unchanged, so editing a released
|
|
116
|
+
* file together with its lock entry is still caught.
|
|
117
|
+
*/
|
|
118
|
+
function releasedChanges(dir, lock, baseRef) {
|
|
119
|
+
try {
|
|
120
|
+
git(dir, [
|
|
121
|
+
"rev-parse",
|
|
122
|
+
"--verify",
|
|
123
|
+
"--quiet",
|
|
124
|
+
`${baseRef}^{commit}`
|
|
125
|
+
]);
|
|
126
|
+
} catch {
|
|
127
|
+
throw new CliError(`Base ref "${baseRef}" not found. Fetch it first (git fetch origin <branch>).`);
|
|
128
|
+
}
|
|
129
|
+
let released;
|
|
130
|
+
try {
|
|
131
|
+
released = JSON.parse(git(dir, ["show", `${baseRef}:./${LOCK_FILE}`]));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
return Object.entries(released).flatMap(([name, expected]) => lock[name] === expected ? [] : [`${name} was released at ${baseRef} and must not change (add a new migration instead)`]);
|
|
136
|
+
}
|
|
137
|
+
// cli/src/status.ts
|
|
138
|
+
function quoteIdentifier(name) {
|
|
139
|
+
return `"${name.replaceAll("\"", "\"\"")}"`;
|
|
140
|
+
}
|
|
141
|
+
function quoteLiteral(value) {
|
|
142
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
143
|
+
}
|
|
144
|
+
async function execute(wrangler, binding, sql, target) {
|
|
145
|
+
return wrangler.exec([
|
|
146
|
+
"d1",
|
|
147
|
+
"execute",
|
|
148
|
+
binding,
|
|
149
|
+
"--json",
|
|
150
|
+
"--command",
|
|
151
|
+
sql,
|
|
152
|
+
...target
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
function rowsOf(stdout) {
|
|
156
|
+
return JSON.parse(stdout).flatMap((statement) => statement.results);
|
|
157
|
+
}
|
|
158
|
+
async function query(wrangler, binding, sql, target) {
|
|
159
|
+
const result = await execute(wrangler, binding, sql, target);
|
|
160
|
+
if (result.code !== 0) throw new CliError(`wrangler d1 execute ${binding} failed:
|
|
161
|
+
${result.stdout}${result.stderr}`);
|
|
162
|
+
return rowsOf(result.stdout);
|
|
163
|
+
}
|
|
164
|
+
const appliedSql = (sources) => sources.map((source) => `SELECT ${quoteLiteral(source.binding)} AS binding, name FROM ${quoteIdentifier(source.table)}`).join(" UNION ALL ");
|
|
165
|
+
/**
|
|
166
|
+
* The applied migrations of every source. Usually one wrangler run (each run
|
|
167
|
+
* is a cold start): every tracking table is read at once. Only when one does
|
|
168
|
+
* not exist yet (never migrated) are the existing tables looked up first.
|
|
169
|
+
*/
|
|
170
|
+
async function readApplied(wrangler, database, binding, target) {
|
|
171
|
+
const all = await execute(wrangler, binding, appliedSql(database.sources), target);
|
|
172
|
+
if (all.code === 0) return rowsOf(all.stdout);
|
|
173
|
+
if (!/no such table/i.test(`${all.stdout}${all.stderr}`)) throw new CliError(`wrangler d1 execute ${binding} failed:
|
|
174
|
+
${all.stdout}${all.stderr}`);
|
|
175
|
+
const tables = new Set((await query(wrangler, binding, "SELECT name FROM sqlite_master WHERE type = 'table'", target)).map((row) => row.name));
|
|
176
|
+
const tracked = database.sources.filter((source) => tables.has(source.table));
|
|
177
|
+
return tracked.length === 0 ? [] : query(wrangler, binding, appliedSql(tracked), target);
|
|
178
|
+
}
|
|
179
|
+
/** Applied and pending migrations of every source of one database, read through wrangler. */
|
|
180
|
+
async function readStatus(wrangler, database, target) {
|
|
181
|
+
const [first] = database.sources;
|
|
182
|
+
if (first === void 0) return [];
|
|
183
|
+
const applied = await readApplied(wrangler, database, first.binding, target);
|
|
184
|
+
return database.sources.flatMap((source) => {
|
|
185
|
+
const appliedHere = new Set(applied.filter((row) => row.binding === source.binding).map((row) => row.name));
|
|
186
|
+
const files = migrationFiles(source);
|
|
187
|
+
const rows = files.map((migration) => ({
|
|
188
|
+
binding: source.binding,
|
|
189
|
+
table: source.table,
|
|
190
|
+
migration,
|
|
191
|
+
status: appliedHere.has(migration) ? "applied" : "pending"
|
|
192
|
+
}));
|
|
193
|
+
for (const migration of appliedHere) if (!files.includes(migration)) rows.push({
|
|
194
|
+
binding: source.binding,
|
|
195
|
+
table: source.table,
|
|
196
|
+
migration,
|
|
197
|
+
status: "applied, file missing"
|
|
198
|
+
});
|
|
199
|
+
return rows;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/** Renders the status as an aligned plain-text table (stable for CI logs and tests). */
|
|
203
|
+
function formatStatus(database, target, rows) {
|
|
204
|
+
const header = [
|
|
205
|
+
"binding",
|
|
206
|
+
"table",
|
|
207
|
+
"migration",
|
|
208
|
+
"status"
|
|
209
|
+
];
|
|
210
|
+
const cells = [header, ...rows.map((row) => [
|
|
211
|
+
row.binding,
|
|
212
|
+
row.table,
|
|
213
|
+
row.migration,
|
|
214
|
+
row.status
|
|
215
|
+
])];
|
|
216
|
+
const widths = header.map((_, column) => Math.max(...cells.map((line) => line[column]?.length ?? 0)));
|
|
217
|
+
const lines = cells.map((line) => line.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd());
|
|
218
|
+
const pending = rows.filter((row) => row.status === "pending").length;
|
|
219
|
+
return [
|
|
220
|
+
`Migration status for D1 database "${database}" (${target}):`,
|
|
221
|
+
...lines,
|
|
222
|
+
`${rows.length - pending} applied, ${pending} pending`,
|
|
223
|
+
""
|
|
224
|
+
].join("\n");
|
|
225
|
+
}
|
|
226
|
+
// cli/src/wrangler.ts
|
|
227
|
+
/**
|
|
228
|
+
* Locates the application's wrangler: the one installed next to its config,
|
|
229
|
+
* falling back to the one installed with this package.
|
|
230
|
+
*/
|
|
231
|
+
function resolveWrangler(fromDir) {
|
|
232
|
+
for (const base of [join(fromDir, "noop.js"), import.meta.url]) try {
|
|
233
|
+
const require = createRequire(base);
|
|
234
|
+
return {
|
|
235
|
+
root: dirname(require.resolve("wrangler/package.json")),
|
|
236
|
+
main: require.resolve("wrangler")
|
|
237
|
+
};
|
|
238
|
+
} catch {}
|
|
239
|
+
throw new Error("wrangler is not installed. Add it to your app: pnpm add -D wrangler");
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* How long one wrangler run may take (default 5 min, room for a slow remote
|
|
243
|
+
* `migrations apply`). Override with `INBIS_DB_WRANGLER_TIMEOUT_MS`.
|
|
244
|
+
*/
|
|
245
|
+
const DEFAULT_WRANGLER_TIMEOUT_MS = 3e5;
|
|
246
|
+
function timeoutFromEnv() {
|
|
247
|
+
const value = Number(process.env["INBIS_DB_WRANGLER_TIMEOUT_MS"]);
|
|
248
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_WRANGLER_TIMEOUT_MS;
|
|
249
|
+
}
|
|
250
|
+
var Wrangler = class {
|
|
251
|
+
#root;
|
|
252
|
+
#main;
|
|
253
|
+
#cwd;
|
|
254
|
+
#timeoutMs;
|
|
255
|
+
constructor(cwd, { timeoutMs = timeoutFromEnv() } = {}) {
|
|
256
|
+
this.#cwd = cwd;
|
|
257
|
+
this.#timeoutMs = timeoutMs;
|
|
258
|
+
({root: this.#root, main: this.#main} = resolveWrangler(cwd));
|
|
259
|
+
}
|
|
260
|
+
async readConfig(configPath, env) {
|
|
261
|
+
return (await import(pathToFileURL(this.#main).href)).unstable_readConfig(env === void 0 ? { config: configPath } : {
|
|
262
|
+
config: configPath,
|
|
263
|
+
env
|
|
264
|
+
}, { hideWarnings: true });
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Runs `wrangler <args>`; resolves with its exit code and combined output. A
|
|
268
|
+
* run that exceeds the timeout is stopped (SIGTERM, then SIGKILL) and resolves
|
|
269
|
+
* with code {@link TIMED_OUT_CODE}, its output so far and a note in `stderr`.
|
|
270
|
+
*/
|
|
271
|
+
exec(args) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const child = spawn(process.execPath, [join(this.#root, "bin", "wrangler.js"), ...args], {
|
|
274
|
+
cwd: this.#cwd,
|
|
275
|
+
env: {
|
|
276
|
+
...process.env,
|
|
277
|
+
WRANGLER_SEND_METRICS: "false",
|
|
278
|
+
FORCE_COLOR: "0"
|
|
279
|
+
},
|
|
280
|
+
stdio: [
|
|
281
|
+
"ignore",
|
|
282
|
+
"pipe",
|
|
283
|
+
"pipe"
|
|
284
|
+
]
|
|
285
|
+
});
|
|
286
|
+
let stdout = "";
|
|
287
|
+
let stderr = "";
|
|
288
|
+
let timedOut = false;
|
|
289
|
+
let kill;
|
|
290
|
+
const timer = setTimeout(() => {
|
|
291
|
+
timedOut = true;
|
|
292
|
+
child.kill("SIGTERM");
|
|
293
|
+
kill = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
294
|
+
}, this.#timeoutMs);
|
|
295
|
+
child.stdout.on("data", (chunk) => {
|
|
296
|
+
stdout += chunk.toString();
|
|
297
|
+
});
|
|
298
|
+
child.stderr.on("data", (chunk) => {
|
|
299
|
+
stderr += chunk.toString();
|
|
300
|
+
});
|
|
301
|
+
child.on("error", (error) => {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
clearTimeout(kill);
|
|
304
|
+
reject(error);
|
|
305
|
+
});
|
|
306
|
+
child.on("close", (code) => {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
clearTimeout(kill);
|
|
309
|
+
if (!timedOut) {
|
|
310
|
+
resolve({
|
|
311
|
+
code: code ?? 1,
|
|
312
|
+
stdout,
|
|
313
|
+
stderr
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const note = `wrangler ${args.join(" ")} did not finish within ${this.#timeoutMs / 1e3} s and was stopped.`;
|
|
318
|
+
resolve({
|
|
319
|
+
code: 124,
|
|
320
|
+
stdout,
|
|
321
|
+
stderr: `${stderr}${stderr === "" || stderr.endsWith("\n") ? "" : "\n"}${note}\n`
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
// cli/src/run.ts
|
|
328
|
+
const USAGE = `Usage: inbis-db <command> [options]
|
|
329
|
+
|
|
330
|
+
Commands:
|
|
331
|
+
migrate Apply every migration source of each D1 database in declaration
|
|
332
|
+
order (platform bindings first, then the app), then print the status.
|
|
333
|
+
status Print applied and pending migrations per source.
|
|
334
|
+
lock <dir> Add checksums of new migration files to <dir>/migrations.lock.json.
|
|
335
|
+
check-lock <dir> [--base <git-ref>]
|
|
336
|
+
Fail if a locked migration changed or was deleted, a file is not
|
|
337
|
+
locked, or (with --base) a migration locked at <git-ref> changed.
|
|
338
|
+
|
|
339
|
+
migrate refuses to run if a locked migration file has changed.
|
|
340
|
+
|
|
341
|
+
Options:
|
|
342
|
+
--local | --remote Target local (default) or remote D1.
|
|
343
|
+
--env <name> Wrangler environment.
|
|
344
|
+
--config <path> Wrangler config (default: wrangler.jsonc/.json/.toml in cwd).
|
|
345
|
+
--persist-to <dir> Local D1 state directory (wrangler --persist-to).
|
|
346
|
+
`;
|
|
347
|
+
const options = {
|
|
348
|
+
local: { type: "boolean" },
|
|
349
|
+
remote: { type: "boolean" },
|
|
350
|
+
env: { type: "string" },
|
|
351
|
+
config: { type: "string" },
|
|
352
|
+
"persist-to": { type: "string" },
|
|
353
|
+
base: { type: "string" },
|
|
354
|
+
help: {
|
|
355
|
+
type: "boolean",
|
|
356
|
+
short: "h"
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
function findConfig(cwd, explicit) {
|
|
360
|
+
if (explicit !== void 0) return resolve(cwd, explicit);
|
|
361
|
+
for (const name of [
|
|
362
|
+
"wrangler.jsonc",
|
|
363
|
+
"wrangler.json",
|
|
364
|
+
"wrangler.toml"
|
|
365
|
+
]) if (existsSync(join(cwd, name))) return join(cwd, name);
|
|
366
|
+
throw new CliError(`No wrangler config found in ${cwd}. Pass --config <path>.`);
|
|
367
|
+
}
|
|
368
|
+
async function context(values, io) {
|
|
369
|
+
if (values.local === true && values.remote === true) throw new CliError("Pass either --local or --remote, not both.");
|
|
370
|
+
const target = values.remote === true ? "remote" : "local";
|
|
371
|
+
const configPath = findConfig(io.cwd, values.config);
|
|
372
|
+
const wrangler = new Wrangler(io.cwd);
|
|
373
|
+
const config = await wrangler.readConfig(configPath, values.env);
|
|
374
|
+
const targetArgs = [
|
|
375
|
+
`--${target}`,
|
|
376
|
+
"--config",
|
|
377
|
+
configPath
|
|
378
|
+
];
|
|
379
|
+
if (values.env !== void 0) targetArgs.push("--env", values.env);
|
|
380
|
+
if (values["persist-to"] !== void 0) targetArgs.push("--persist-to", resolve(io.cwd, values["persist-to"]));
|
|
381
|
+
return {
|
|
382
|
+
io,
|
|
383
|
+
wrangler,
|
|
384
|
+
target,
|
|
385
|
+
databases: databaseSources(config, target),
|
|
386
|
+
targetArgs
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async function printStatus(ctx, database) {
|
|
390
|
+
const rows = await readStatus(ctx.wrangler, database, ctx.targetArgs);
|
|
391
|
+
ctx.io.stdout(formatStatus(database.database, ctx.target, rows));
|
|
392
|
+
return rows;
|
|
393
|
+
}
|
|
394
|
+
async function migrate(ctx) {
|
|
395
|
+
let failed = false;
|
|
396
|
+
for (const database of ctx.databases) {
|
|
397
|
+
const drift = database.sources.flatMap((source) => {
|
|
398
|
+
const lock = readLock(source.dir);
|
|
399
|
+
return lock === void 0 ? [] : lockDrift(source.dir, lock).map((problem) => `${source.binding}: ${problem}`);
|
|
400
|
+
});
|
|
401
|
+
if (drift.length > 0) {
|
|
402
|
+
ctx.io.stderr(`Refusing to migrate "${database.database}"; locked migrations differ from ${LOCK_FILE}:\n ${drift.join("\n ")}\n`);
|
|
403
|
+
failed = true;
|
|
404
|
+
}
|
|
405
|
+
for (const source of database.sources) {
|
|
406
|
+
if (failed) break;
|
|
407
|
+
if (migrationFiles(source).length === 0) continue;
|
|
408
|
+
ctx.io.stdout(`Applying ${source.binding} migrations (${source.table})...\n`);
|
|
409
|
+
const result = await ctx.wrangler.exec([
|
|
410
|
+
"d1",
|
|
411
|
+
"migrations",
|
|
412
|
+
"apply",
|
|
413
|
+
source.binding,
|
|
414
|
+
...ctx.targetArgs
|
|
415
|
+
]);
|
|
416
|
+
if (result.code !== 0) {
|
|
417
|
+
ctx.io.stderr(`${result.stdout}${result.stderr}\nApplying ${source.binding} migrations failed; later sources were not applied.\n`);
|
|
418
|
+
failed = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await printStatus(ctx, database);
|
|
422
|
+
}
|
|
423
|
+
return failed ? 1 : 0;
|
|
424
|
+
}
|
|
425
|
+
function lockDir(positionals, io) {
|
|
426
|
+
const dir = positionals[1];
|
|
427
|
+
if (dir === void 0) throw new CliError("Pass the migrations directory.");
|
|
428
|
+
return resolve(io.cwd, dir);
|
|
429
|
+
}
|
|
430
|
+
function lock(positionals, io) {
|
|
431
|
+
const { added } = updateLock(lockDir(positionals, io));
|
|
432
|
+
io.stdout(added.length === 0 ? "Lock is up to date.\n" : `Locked ${added.join(", ")}.\n`);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
function checkLock(positionals, values, io) {
|
|
436
|
+
const dir = lockDir(positionals, io);
|
|
437
|
+
const current = readLock(dir);
|
|
438
|
+
if (current === void 0) throw new CliError(`${join(dir, LOCK_FILE)} not found; run inbis-db lock.`);
|
|
439
|
+
const problems = [...lockProblems(dir, current), ...values.base === void 0 ? [] : releasedChanges(dir, current, values.base)];
|
|
440
|
+
if (problems.length > 0) {
|
|
441
|
+
io.stderr(`Migration lock check failed for ${dir}:\n ${problems.join("\n ")}\n`);
|
|
442
|
+
return 1;
|
|
443
|
+
}
|
|
444
|
+
io.stdout(`${Object.keys(current).length} locked migrations unchanged in ${dir}.\n`);
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
async function status(ctx) {
|
|
448
|
+
for (const database of ctx.databases) await printStatus(ctx, database);
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
/** Runs the inbis-db CLI with `argv` (without node and script); resolves with the exit code. */
|
|
452
|
+
async function run(argv, io) {
|
|
453
|
+
try {
|
|
454
|
+
const { values, positionals } = parseArgs({
|
|
455
|
+
args: argv,
|
|
456
|
+
options,
|
|
457
|
+
allowPositionals: true
|
|
458
|
+
});
|
|
459
|
+
const [command] = positionals;
|
|
460
|
+
if (values.help === true || command === void 0) {
|
|
461
|
+
io.stdout(USAGE);
|
|
462
|
+
return command === void 0 && values.help !== true ? 1 : 0;
|
|
463
|
+
}
|
|
464
|
+
switch (command) {
|
|
465
|
+
case "migrate": return await migrate(await context(values, io));
|
|
466
|
+
case "status": return await status(await context(values, io));
|
|
467
|
+
case "lock": return lock(positionals, io);
|
|
468
|
+
case "check-lock": return checkLock(positionals, values, io);
|
|
469
|
+
default: throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (error instanceof CliError || error instanceof TypeError && "code" in error) {
|
|
473
|
+
io.stderr(`inbis-db: ${error.message}\n`);
|
|
474
|
+
return 1;
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// cli/src/main.ts
|
|
480
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
481
|
+
cwd: process.cwd(),
|
|
482
|
+
stdout: (text) => process.stdout.write(text),
|
|
483
|
+
stderr: (text) => process.stderr.write(text)
|
|
484
|
+
});
|
|
485
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DrizzleD1Database } from "drizzle-orm/d1";
|
|
2
|
+
import { BatchItem, BatchResponse } from "drizzle-orm/batch";
|
|
3
|
+
type Database<TSchema extends Record<string, unknown> = Record<string, never>> = DrizzleD1Database<TSchema>;
|
|
4
|
+
interface CreateDatabaseOptions<TSchema extends Record<string, unknown>> {
|
|
5
|
+
/** Drizzle tables (and relations) to enable the `db.query.*` API for. */
|
|
6
|
+
schema?: TSchema;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wraps a D1 binding in a Drizzle instance. Create one per request from the
|
|
10
|
+
* binding you were given; never read bindings from globals.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createDatabase<TSchema extends Record<string, unknown> = Record<string, never>>(d1: D1Database, options?: CreateDatabaseOptions<TSchema>): Database<TSchema>;
|
|
13
|
+
/**
|
|
14
|
+
* Runs statements atomically in one D1 round trip. D1 has no interactive
|
|
15
|
+
* transactions, so this is the way to make several writes all-or-nothing.
|
|
16
|
+
*
|
|
17
|
+
* Unique-constraint violations become a 409 `CONFLICT` {@link AppError};
|
|
18
|
+
* other database errors are rethrown unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare function batch<TSchema extends Record<string, unknown>, const T extends readonly BatchItem<"sqlite">[]>(db: Database<TSchema>, statements: T): Promise<BatchResponse<T>>;
|
|
21
|
+
/** Text primary key filled with a time-ordered UUIDv7 (see `createId` in `@awsmbiz/inbis-core`). */
|
|
22
|
+
export declare function idColumn(name?: string): import("drizzle-orm").HasRuntimeDefault<import("drizzle-orm").HasDefault<import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").NotNull<import("drizzle-orm/sqlite-core").SQLiteTextBuilderInitial<string, [string, ...string[]], number | undefined>>>>>;
|
|
23
|
+
/**
|
|
24
|
+
* `createdAt` / `updatedAt` columns (`created_at` / `updated_at`, epoch
|
|
25
|
+
* milliseconds). Both are set on insert; `updatedAt` is refreshed on update.
|
|
26
|
+
*/
|
|
27
|
+
export declare function timestampColumns(): {
|
|
28
|
+
createdAt: import("drizzle-orm").HasRuntimeDefault<import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/sqlite-core").SQLiteTimestampBuilderInitial<"created_at">>>>;
|
|
29
|
+
updatedAt: import("drizzle-orm").HasDefault<import("drizzle-orm").HasRuntimeDefault<import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/sqlite-core").SQLiteTimestampBuilderInitial<"updated_at">>>>>;
|
|
30
|
+
};
|
|
31
|
+
/** Raw paging input, typically straight from the query string. */
|
|
32
|
+
interface PageInput {
|
|
33
|
+
limit?: string | number | undefined;
|
|
34
|
+
cursor?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
interface PageOptions {
|
|
37
|
+
/** Items per page when no limit is given. Default 20. */
|
|
38
|
+
defaultLimit?: number;
|
|
39
|
+
/** Larger limits are clamped to this. Default 100. */
|
|
40
|
+
maxLimit?: number;
|
|
41
|
+
}
|
|
42
|
+
interface PageRequest {
|
|
43
|
+
/** Items to return. */
|
|
44
|
+
limit: number;
|
|
45
|
+
/** Rows to fetch: `limit + 1`, so {@link toPage} can tell whether more exist. */
|
|
46
|
+
fetchLimit: number;
|
|
47
|
+
/** Sort key of the last item on the previous page; fetch rows strictly after it. */
|
|
48
|
+
after: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
interface Page<T> {
|
|
51
|
+
items: T[];
|
|
52
|
+
/** Opaque cursor for the next page, or `null` on the last page. */
|
|
53
|
+
nextCursor: string | null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Validates paging input for keyset pagination over a unique, ordered key
|
|
57
|
+
* (e.g. a UUIDv7 `id`). Query `WHERE key > after ORDER BY key LIMIT fetchLimit`,
|
|
58
|
+
* then pass the rows to {@link toPage}.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parsePageRequest(input: PageInput, options?: PageOptions): PageRequest;
|
|
61
|
+
/** Trims the extra fetched row and derives the next cursor from the last item's key. */
|
|
62
|
+
export declare function toPage<T>(rows: T[], request: PageRequest, keyOf: (row: T) => string): Page<T>;
|
|
63
|
+
export type { CreateDatabaseOptions, Database, Page, PageInput, PageOptions, PageRequest };
|
|
64
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AppError, createId, validationFailed } from "@awsmbiz/inbis-core";
|
|
2
|
+
import { integer, text } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
4
|
+
// src/batch.ts
|
|
5
|
+
/**
|
|
6
|
+
* Runs statements atomically in one D1 round trip. D1 has no interactive
|
|
7
|
+
* transactions, so this is the way to make several writes all-or-nothing.
|
|
8
|
+
*
|
|
9
|
+
* Unique-constraint violations become a 409 `CONFLICT` {@link AppError};
|
|
10
|
+
* other database errors are rethrown unchanged.
|
|
11
|
+
*/
|
|
12
|
+
async function batch(db, statements) {
|
|
13
|
+
if (statements.length === 0) return [];
|
|
14
|
+
try {
|
|
15
|
+
return await db.batch.bind(db)(statements);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw toAppError(error);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function toAppError(error) {
|
|
21
|
+
for (let current = error; current instanceof Error; current = current.cause) if (current.message.includes("UNIQUE constraint failed")) return new AppError({
|
|
22
|
+
code: "CONFLICT",
|
|
23
|
+
status: 409,
|
|
24
|
+
message: "The resource already exists.",
|
|
25
|
+
cause: error
|
|
26
|
+
});
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
// src/columns.ts
|
|
30
|
+
/** Text primary key filled with a time-ordered UUIDv7 (see `createId` in `@awsmbiz/inbis-core`). */
|
|
31
|
+
function idColumn(name = "id") {
|
|
32
|
+
return text(name).primaryKey().$defaultFn(() => createId());
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `createdAt` / `updatedAt` columns (`created_at` / `updated_at`, epoch
|
|
36
|
+
* milliseconds). Both are set on insert; `updatedAt` is refreshed on update.
|
|
37
|
+
*/
|
|
38
|
+
function timestampColumns() {
|
|
39
|
+
return {
|
|
40
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
41
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// src/database.ts
|
|
45
|
+
/**
|
|
46
|
+
* Wraps a D1 binding in a Drizzle instance. Create one per request from the
|
|
47
|
+
* binding you were given; never read bindings from globals.
|
|
48
|
+
*/
|
|
49
|
+
function createDatabase(d1, options = {}) {
|
|
50
|
+
return options.schema === void 0 ? drizzle(d1) : drizzle(d1, { schema: options.schema });
|
|
51
|
+
}
|
|
52
|
+
// src/pagination.ts
|
|
53
|
+
/**
|
|
54
|
+
* Validates paging input for keyset pagination over a unique, ordered key
|
|
55
|
+
* (e.g. a UUIDv7 `id`). Query `WHERE key > after ORDER BY key LIMIT fetchLimit`,
|
|
56
|
+
* then pass the rows to {@link toPage}.
|
|
57
|
+
*/
|
|
58
|
+
function parsePageRequest(input, options = {}) {
|
|
59
|
+
const maxLimit = options.maxLimit ?? 100;
|
|
60
|
+
const limit = Math.min(parseLimit(input.limit, options.defaultLimit ?? 20), maxLimit);
|
|
61
|
+
const after = input.cursor === void 0 || input.cursor === "" ? void 0 : decodeCursor(input.cursor);
|
|
62
|
+
return {
|
|
63
|
+
limit,
|
|
64
|
+
fetchLimit: limit + 1,
|
|
65
|
+
after
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Trims the extra fetched row and derives the next cursor from the last item's key. */
|
|
69
|
+
function toPage(rows, request, keyOf) {
|
|
70
|
+
const items = rows.slice(0, request.limit);
|
|
71
|
+
const last = items.at(-1);
|
|
72
|
+
return {
|
|
73
|
+
items,
|
|
74
|
+
nextCursor: rows.length > request.limit && last !== void 0 ? encodeCursor(keyOf(last)) : null
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function parseLimit(raw, fallback) {
|
|
78
|
+
if (raw === void 0 || raw === "") return fallback;
|
|
79
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
80
|
+
if (!Number.isInteger(value) || value < 1) throw validationFailed([{
|
|
81
|
+
path: ["limit"],
|
|
82
|
+
message: "Must be a positive integer."
|
|
83
|
+
}]);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function encodeCursor(key) {
|
|
87
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ k: key }));
|
|
88
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
89
|
+
}
|
|
90
|
+
function decodeCursor(cursor) {
|
|
91
|
+
try {
|
|
92
|
+
const binary = atob(cursor.replaceAll("-", "+").replaceAll("_", "/"));
|
|
93
|
+
const json = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
94
|
+
const parsed = JSON.parse(json);
|
|
95
|
+
if (typeof parsed === "object" && parsed !== null && "k" in parsed && typeof parsed.k === "string") return parsed.k;
|
|
96
|
+
} catch {}
|
|
97
|
+
throw new AppError({
|
|
98
|
+
code: "INVALID_CURSOR",
|
|
99
|
+
status: 400,
|
|
100
|
+
message: "The page cursor is invalid."
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export { batch, createDatabase, idColumn, parsePageRequest, timestampColumns, toPage };
|
|
104
|
+
|
|
105
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/batch.ts","../src/columns.ts","../src/database.ts","../src/pagination.ts"],"sourcesContent":["import { AppError } from \"@awsmbiz/inbis-core\";\nimport type { BatchItem, BatchResponse } from \"drizzle-orm/batch\";\nimport type { Database } from \"./database.js\";\n\n/**\n * Runs statements atomically in one D1 round trip. D1 has no interactive\n * transactions, so this is the way to make several writes all-or-nothing.\n *\n * Unique-constraint violations become a 409 `CONFLICT` {@link AppError};\n * other database errors are rethrown unchanged.\n */\nexport async function batch<\n TSchema extends Record<string, unknown>,\n const T extends readonly BatchItem<\"sqlite\">[],\n>(db: Database<TSchema>, statements: T): Promise<BatchResponse<T>> {\n if (statements.length === 0) return [] as BatchResponse<T>;\n try {\n const run = db.batch.bind(db) as unknown as (items: T) => Promise<BatchResponse<T>>;\n return await run(statements);\n } catch (error) {\n throw toAppError(error);\n }\n}\n\nfunction toAppError(error: unknown): unknown {\n for (let current: unknown = error; current instanceof Error; current = current.cause) {\n if (current.message.includes(\"UNIQUE constraint failed\")) {\n return new AppError({\n code: \"CONFLICT\",\n status: 409,\n message: \"The resource already exists.\",\n cause: error,\n });\n }\n }\n return error;\n}\n","import { createId } from \"@awsmbiz/inbis-core\";\nimport { integer, text } from \"drizzle-orm/sqlite-core\";\n\n/** Text primary key filled with a time-ordered UUIDv7 (see `createId` in `@awsmbiz/inbis-core`). */\nexport function idColumn(name = \"id\") {\n return text(name)\n .primaryKey()\n .$defaultFn(() => createId());\n}\n\n/**\n * `createdAt` / `updatedAt` columns (`created_at` / `updated_at`, epoch\n * milliseconds). Both are set on insert; `updatedAt` is refreshed on update.\n */\nexport function timestampColumns() {\n return {\n createdAt: integer(\"created_at\", { mode: \"timestamp_ms\" })\n .notNull()\n .$defaultFn(() => new Date()),\n updatedAt: integer(\"updated_at\", { mode: \"timestamp_ms\" })\n .notNull()\n .$defaultFn(() => new Date())\n .$onUpdateFn(() => new Date()),\n };\n}\n","import { drizzle, type DrizzleD1Database } from \"drizzle-orm/d1\";\n\nexport type Database<TSchema extends Record<string, unknown> = Record<string, never>> =\n DrizzleD1Database<TSchema>;\n\nexport interface CreateDatabaseOptions<TSchema extends Record<string, unknown>> {\n /** Drizzle tables (and relations) to enable the `db.query.*` API for. */\n schema?: TSchema;\n}\n\n/**\n * Wraps a D1 binding in a Drizzle instance. Create one per request from the\n * binding you were given; never read bindings from globals.\n */\nexport function createDatabase<TSchema extends Record<string, unknown> = Record<string, never>>(\n d1: D1Database,\n options: CreateDatabaseOptions<TSchema> = {},\n): Database<TSchema> {\n return options.schema === undefined\n ? (drizzle(d1))\n : drizzle(d1, { schema: options.schema });\n}\n","import { AppError, validationFailed } from \"@awsmbiz/inbis-core\";\n\n/** Raw paging input, typically straight from the query string. */\nexport interface PageInput {\n limit?: string | number | undefined;\n cursor?: string | undefined;\n}\n\nexport interface PageOptions {\n /** Items per page when no limit is given. Default 20. */\n defaultLimit?: number;\n /** Larger limits are clamped to this. Default 100. */\n maxLimit?: number;\n}\n\nexport interface PageRequest {\n /** Items to return. */\n limit: number;\n /** Rows to fetch: `limit + 1`, so {@link toPage} can tell whether more exist. */\n fetchLimit: number;\n /** Sort key of the last item on the previous page; fetch rows strictly after it. */\n after: string | undefined;\n}\n\nexport interface Page<T> {\n items: T[];\n /** Opaque cursor for the next page, or `null` on the last page. */\n nextCursor: string | null;\n}\n\n/**\n * Validates paging input for keyset pagination over a unique, ordered key\n * (e.g. a UUIDv7 `id`). Query `WHERE key > after ORDER BY key LIMIT fetchLimit`,\n * then pass the rows to {@link toPage}.\n */\nexport function parsePageRequest(input: PageInput, options: PageOptions = {}): PageRequest {\n const maxLimit = options.maxLimit ?? 100;\n const limit = Math.min(parseLimit(input.limit, options.defaultLimit ?? 20), maxLimit);\n const after = input.cursor === undefined || input.cursor === \"\" ? undefined : decodeCursor(input.cursor);\n return { limit, fetchLimit: limit + 1, after };\n}\n\n/** Trims the extra fetched row and derives the next cursor from the last item's key. */\nexport function toPage<T>(rows: T[], request: PageRequest, keyOf: (row: T) => string): Page<T> {\n const items = rows.slice(0, request.limit);\n const last = items.at(-1);\n const hasMore = rows.length > request.limit && last !== undefined;\n return { items, nextCursor: hasMore ? encodeCursor(keyOf(last)) : null };\n}\n\nfunction parseLimit(raw: string | number | undefined, fallback: number): number {\n if (raw === undefined || raw === \"\") return fallback;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n if (!Number.isInteger(value) || value < 1) {\n throw validationFailed([{ path: [\"limit\"], message: \"Must be a positive integer.\" }]);\n }\n return value;\n}\n\nfunction encodeCursor(key: string): string {\n const bytes = new TextEncoder().encode(JSON.stringify({ k: key }));\n return btoa(String.fromCharCode(...bytes))\n .replaceAll(\"+\", \"-\")\n .replaceAll(\"/\", \"_\")\n .replace(/=+$/, \"\");\n}\n\nfunction decodeCursor(cursor: string): string {\n try {\n const binary = atob(cursor.replaceAll(\"-\", \"+\").replaceAll(\"_\", \"/\"));\n const json = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));\n const parsed: unknown = JSON.parse(json);\n if (typeof parsed === \"object\" && parsed !== null && \"k\" in parsed && typeof parsed.k === \"string\") {\n return parsed.k;\n }\n } catch {\n // fall through to the typed error below\n }\n throw new AppError({ code: \"INVALID_CURSOR\", status: 400, message: \"The page cursor is invalid.\" });\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awsmbiz/inbis-db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "D1 + Drizzle helpers for Inbis packages: database factory, column and pagination helpers, batch wrapper, and the inbis-db migration CLI.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/awsmbiz/inbis-platform.git",
|
|
9
|
+
"directory": "packages/db"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"registry": "https://registry.npmjs.org/",
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@awsmbiz/inbis-core": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"drizzle-orm": "^0.45.4",
|
|
32
|
+
"wrangler": "^4.156.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
36
|
+
"@cloudflare/vitest-plugin": "1.4.11",
|
|
37
|
+
"@cloudflare/workers-types": "5.20261030.1",
|
|
38
|
+
"@awsmbiz/inbis-eslint-config": "0.1.0",
|
|
39
|
+
"@awsmbiz/inbis-tsconfig": "0.1.0",
|
|
40
|
+
"@types/node": "24.19.2",
|
|
41
|
+
"drizzle-orm": "0.45.4",
|
|
42
|
+
"eslint": "10.14.0",
|
|
43
|
+
"publint": "0.3.25",
|
|
44
|
+
"tsdown": "0.23.4",
|
|
45
|
+
"typescript": "6.0.3",
|
|
46
|
+
"vitest": "5.0.5",
|
|
47
|
+
"wrangler": "4.156.0"
|
|
48
|
+
},
|
|
49
|
+
"bin": {
|
|
50
|
+
"inbis-db": "./dist/cli.mjs"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"wrangler": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"typecheck": "tsc && tsc -p cli",
|
|
60
|
+
"lint": "eslint . --max-warnings 0",
|
|
61
|
+
"test": "vitest run && vitest run --config cli/vitest.config.ts",
|
|
62
|
+
"check:exports": "publint --strict && attw --pack . --profile esm-only"
|
|
63
|
+
}
|
|
6
64
|
}
|