@glydi/passkey-store-drizzle
v0.1.0
Published
First-party Drizzle/Postgres adapter for Glide passkeys. Schema exposed as data — consumers run their own drizzle-kit generate.
Readme
@glydi/passkey-store-drizzle
First-party Drizzle/Postgres adapter for Glide passkeys. Provides a conformant GlideStore backed by two PostgreSQL tables — schema exposed as data, zero numbered .sql files.
Quick Start
import { drizzle } from "drizzle-orm/node-postgres";
import { createDrizzleStore } from "@glydi/passkey-store-drizzle";
import { createGlideServerFromEnv } from "@glydi/passkey-server";
import { usersTable } from "./schema"; // your own users table
const db = drizzle(pool);
const store = createDrizzleStore({
db,
usersTable,
userIdColumn: usersTable.id,
usernameColumn: usersTable.email, // required for findByUsername / upsert(email)
});
const glide = createGlideServerFromEnv({ store });
createGlideServerFromEnvdefaults:userVerificationdefaults to"preferred". SetGLIDE_USER_VERIFICATION=requiredin your environment for stricter behavior (recommended for production).GLIDE_RP_NAMEis required — set it to your app's display name (e.g."My App").
Add the passkey tables to your schema barrel:
// schema.ts (your schema barrel)
import { buildSchema } from "@glydi/passkey-store-drizzle/schema";
import { usersTable } from "./users";
export const { passkeyCredentials, passkeyChallenges } = buildSchema({
usersTable,
userIdColumn: usersTable.id,
});Then run your own migration generator — you own your data, you own your migrations:
npx drizzle-kit generateConsumer-Owns-Migrations
This adapter ships no numbered .sql files. Instead, the ./schema subpath exports live Drizzle pgTable objects via buildSchema(). Import them into your own schema barrel alongside your existing tables and run drizzle-kit generate to produce SQL migrations that fit into your own sequence.
This avoids the 0013 migration collision problem seen when adapters ship their own numbered files that overlap with a consumer's migration counter.
Schema Changelog
SCHEMA_VERSION is exported from @glydi/passkey-store-drizzle/schema so your app can assert compatibility at startup.
v2
Columns widened in passkey_credentials:
| Column | v1 Type | v2 Type | Reason |
|--------|---------|---------|--------|
| counter | INTEGER | BIGINT | WebAuthn signature counters are uint32 (max 4,294,967,295), which exceeds PostgreSQL INT4 max of 2,147,483,647 |
| created_at | INTEGER | BIGINT | Stored as unix milliseconds; Date.now() ≈ 1.75 × 10¹² overflows INT4 |
Migration for existing deployments: Regenerate your migration from buildSchema() and apply:
ALTER TABLE passkey_credentials
ALTER COLUMN counter TYPE BIGINT,
ALTER COLUMN created_at TYPE BIGINT;This is a safe, backward-compatible widening — no data is lost and no application code changes are required.
SCHEMA_VERSION exported from @glydi/passkey-store-drizzle/schema is now 2. You can assert compatibility at startup:
import { SCHEMA_VERSION } from "@glydi/passkey-store-drizzle/schema";
assert(SCHEMA_VERSION === 2, "passkey schema version mismatch");v1
Tables introduced: passkey_credentials, passkey_challenges
passkey_credentials
| Column | Type | Notes |
|--------|------|-------|
| credential_id | TEXT PRIMARY KEY | WebAuthn credential ID (base64url) |
| user_id | TEXT NOT NULL | FK → your users table id column |
| public_key | BYTEA NOT NULL | Raw COSE public key bytes |
| counter | BIGINT NOT NULL DEFAULT 0 | Signature counter (replay protection) |
| transports | TEXT | JSON-serialized array of AuthenticatorTransport |
| device_type | TEXT | "singleDevice" or "multiDevice" |
| backed_up | INTEGER | 1 = backed up, 0 = not, NULL = unknown |
| name | TEXT | Consumer-supplied display name |
| created_at | BIGINT | Unix milliseconds timestamp |
Index: idx_passkey_credentials_user on user_id.
FK: fk_passkey_credentials_user → userIdColumn argument passed to buildSchema().
passkey_challenges
| Column | Type | Notes |
|--------|------|-------|
| session_id | TEXT PRIMARY KEY | Challenge session identifier |
| challenge | TEXT NOT NULL | WebAuthn challenge (base64url) |
| expires | BIGINT NOT NULL | Unix milliseconds — BIGINT avoids INT4 overflow of Date.now() |
expires is stored as unix milliseconds. BIGINT (not INTEGER) is required because Date.now() returns ~1.7 trillion ms, which exceeds PostgreSQL INT4's maximum of ~2.1 billion. The adapter uses lazy TTL eviction: expired challenges are deleted on read, no background sweeper.
Upgrade notes: No prior version exists. Fresh install — run drizzle-kit generate after adding buildSchema() output to your schema barrel.
