Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
# Changelog

The three packages (`pgstencil`, `@pgstencil/auth`, `@pgstencil/stripe`) share one version. They are 0.x releases: a minor version may break compatibility, and each breaking change below says how an application adapts. Releases before 0.5.0 are recorded only in the Git history.
The three packages (`pgstencil`, `@pgstencil/auth`, `@pgstencil/stripe`) share one version. They are 0.x releases: a minor version may break compatibility, and each breaking change below says how an application adapts. Releases before 0.5.0 are recorded only in the Git history, with one note for an application upgrading across them: since 0.4.0, `testcontainers`, `node-pg-migrate`, `cheerio` and `turndown` are optional peers that the application installs itself, usually as devDependencies ([PACKAGES.md](PACKAGES.md#tooling-peers)).

## Unreleased

Nothing yet.

## 0.5.1 (2026-11-10)

Fixes and compatible additions found while two applications adopted 0.5.0.

### Fixed

- **`pgstencil create` no longer writes into an installed package.** It wrote into the last configured source without checking, so a project whose sources were all package directories got a new SQL file inside `node_modules`. It now refuses any target inside `node_modules`, the default or one given with `--in`.
- **`pgstencil status`, `validate` and `migrate` refuse a `DATABASE_URL` that is set but empty.** 0.5.0 passed the empty string to the driver, which then connected with its own defaults. An empty value is how a workflow sees a secret it lost.
- **`pgstencil reset` refuses to run while `DATABASE_URL` is set.** It only ever recreates the development database and used to ignore the variable silently.
- **`@pgstencil/auth`: the default sign-in sender is a valid address for an IP origin**: `signin@[127.0.0.1]`, or `signin@[IPv6:…]`, where it was `[email protected]`. A non-blank `emailFrom` is trimmed.
- **This changelog's 0.5.0 entry** gains the notes it was published without: the stripe 23 and Stripe API version move, the Better Auth pin, and the optional tooling peers introduced in 0.4.0.

### Added

- **`pgstencil status|validate|migrate --also <dir>`**, repeatable: one more migration source after the configured ones for that run, for SQL only some databases get, such as previews.
- **`pgstencil status|validate|migrate --require-database-url`**: fail when `DATABASE_URL` is unset instead of using the development database.
- **`--env-file <path>` and `--env-file-if-exists <path>`** on every `pgstencil` command, loaded before configuration is read.
- **`pgstencil/email`** subpath: the email types, `EmailDev` and `escapeHtml` without the root's `node:crypto` import, for code a browser also loads. The `pgstencil` package also declares `"sideEffects": false`, so a production bundle that imports only `escapeHtml` from the root drops the rest.

One message changed: `pgstencil status <argument>` reports `status accepts only --also <dir> and --require-database-url` where it said `status takes no arguments`.

## 0.5.0 (2026-11-10)

This release is breaking.

### Stripe 23 and a new Stripe API version

These notes were missing when 0.5.0 was published.

`@pgstencil/stripe` now peers on `stripe ^23.1.0` (0.4.3 accepted `^22.6.1`), so an application must move its own `stripe` dependency to 23. That moves `STRIPE_API_VERSION` to `2026-10-28.endive`, and `verifyWebhook` refuses an event at any other version, so this upgrade is a webhook cut-over, not a routine bump:

- Create a webhook endpoint at `2026-10-28.endive` and give the new release its signing secret before it serves traffic, then retire the old endpoint. [BILLING.md](BILLING.md#moving-to-a-new-stripe-api-version) has the full procedure.
- Checkout sessions send `allowed_payment_method_types: ['card']` where they sent `payment_method_types: ['card']`, which Stripe 23 removed. A Checkout creation whose response was lost just before the deploy is retried with the new parameters under its saved idempotency key, which Stripe refuses; repair that owner as for any ambiguous creation.
- `verifyWebhook` passes its 300-second tolerance explicitly, so its behaviour is the same under stripe 23, which changed the SDK's default.
- `Billing.reconcileAll({ dryRun, pageSize })` reconciles every owner holding a Stripe customer and reports what changed, for a one-off pass after a cut-over like this one.
- StripeDev models the stripe 23 subscription and Checkout shapes, and gains `pause` and `resume` transitions.

`@pgstencil/auth` moves its exact Better Auth pin from 1.7.16 to 1.7.18, which changes an application's lockfile and any dependency disclosure it publishes.

### Removed: the original code/link authentication

`@pgstencil/auth` is now only the Better Auth integration. The original implementation, which the earlier releases kept beside it, is gone:
Expand Down
9 changes: 6 additions & 3 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ has the steps.
### Sign-in email

The email-code message is sent through the `email` sender you inject.
`emailFrom` sets its sender; without it the sender is
`signin@<hostname of origin>`, so a deployment that forgets it is refused by its
`emailFrom` sets its sender, trimmed; without it, or when it is blank, the
sender is `signin@<hostname of origin>` (`signin@[<address>]` for an IPv4
origin and `signin@[IPv6:<address>]` for an IPv6 one), so a deployment that forgets it is refused by its
mail provider instead of sending as another domain. On Workers, `emailFrom` may
be `(env) => env.EMAIL_FROM`. `postmarkEmail(token, from)` always sends from its
own `from` argument, whatever the message says.
Expand All @@ -131,7 +132,9 @@ or a promise of one; `defaultSignInEmail` is the built-in renderer, which a
custom one can call and adjust. The message must be addressed to `email` alone,
with no `cc` or `bcc`: any other recipient, or a renderer that throws, delivers
nothing and records an `email.delivery.failed` diagnostic. Escape every value
you interpolate into `html` with `escapeHtml` from `pgstencil`. Better Auth
you interpolate into `html` with `escapeHtml` from `pgstencil`, or from
`pgstencil/email` in code a browser also loads: that subpath exports the email
types, `EmailDev` and `escapeHtml` and imports no Node built-in. Better Auth
answers a send request 200 whether or not delivery succeeded, so watch that
diagnostic rather than the response.

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ pnpm db:migrate
}
```

`migrations` is one directory or an ordered list, each inside the project root; it defaults to `migrations`. List a package's source by its installed path and your own directory last, since that is where `create` writes. `schema` names the schema dump. Each `types` entry generates Kysely declarations to `out`, for the tables matching `include` (default `public.*`), minus `exclude`, with `defaultSchema` set when the tables live outside `public`; `types` needs the optional peer `kysely-codegen`. No other key is accepted. The project root is the working directory, or `PGSTENCIL_PROJECT_ROOT`.
`migrations` is one directory or an ordered list, each inside the project root; it defaults to `migrations`. List a package's source by its installed path, which in a pnpm workspace is under the `node_modules` of the workspace package that depends on it (`app/node_modules/@pgstencil/…` when the project root is the workspace root). List your own directory last, since that is where `create` writes; it refuses a target inside `node_modules`. `schema` names the schema dump. Each `types` entry generates Kysely declarations to `out`, for the tables matching `include` (default `public.*`), minus `exclude`, with `defaultSchema` set when the tables live outside `public`; `types` needs the optional peer `kysely-codegen`. No other key is accepted. The project root is the working directory, or `PGSTENCIL_PROJECT_ROOT`.

The schema dump and generated declarations are committed review artifacts, not schema inputs, so editor typechecking works without Docker. Each new migration fingerprint gets its own template; unchanged fingerprints reuse a ready template without rerunning initialization. Failed initializers are discarded, and interrupted initializers without a ready marker are rebuilt. Template cache identity includes SQL filenames/content, the Compose configuration and a format version.

Expand All @@ -136,7 +136,12 @@ Persistent development/production databases record applied-file checksums. Editi
| `pgstencil gc` | `db:gc` | Clear cached test templates/databases when idle |
| `pgstencil stop` | `db:stop` | Stop this checkout's services when idle; retain its Postgres volume |

`DATABASE_URL` directs `status`, `validate` and `migrate` at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. `pnpm check` runs formatting, typechecking and all tests.
`DATABASE_URL` directs `status`, `validate` and `migrate` at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. A `DATABASE_URL` that is set but empty is refused, because that is how a workflow sees a secret it lost. Those three commands also take:

- `--also <dir>`, repeatable: one more migration source after the configured ones, for this run only, such as SQL that only preview databases get. A database that has applied files from an `--also` directory needs the same option on every later run, or validation reports them missing.
- `--require-database-url`: fail when `DATABASE_URL` is unset instead of using the development database. Use it in every deployment script.

Every command takes `--env-file <path>` and `--env-file-if-exists <path>`, repeatable, loaded before `pgstencil.json` is read, so a file can set `PGSTENCIL_PROJECT_ROOT` and `DATABASE_URL`. Variables already in the environment win, and a later file wins over an earlier one. Node itself also notices these two flags: a missing `--env-file` stops in Node with exit code 9, and a missing `--env-file-if-exists` prints Node's own notice. `reset` refuses to run while `DATABASE_URL` is set, since it only ever recreates the development database. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. `pnpm check` runs formatting, typechecking and all tests.

Local services run PostgreSQL 18. The 18 image keeps its data under `/var/lib/postgresql/18/docker` and refuses a volume holding a 17 cluster, so the volume is named `postgres-18-data`. A checkout that last ran PostgreSQL 17 starts a fresh development database; its old volume stays until removed with `docker volume rm <project>_postgres-data`, and `docker volume ls` lists it.

Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pgstencil/auth",
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"exports": {
".": "./src/index.ts",
Expand Down
16 changes: 16 additions & 0 deletions packages/auth/src/better-auth-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ export function isIdentityEmail(email: string) {
return domain === identityDomain || domain.endsWith('.' + identityDomain);
}

/**
* The sign-in email's sender: a non-blank `emailFrom`, trimmed, or
* `signin@<hostname of origin>`. An IP origin gets an RFC 5321 address
* literal (`signin@[127.0.0.1]`, `signin@[IPv6:::1]`), since a bare IP is not
* a valid domain. `origin` is already canonical, so the URL parser has
* normalized an IPv4 host to dotted decimal and bracketed an IPv6 one.
*/
export function signInSender(emailFrom: unknown, origin: string): string {
const configured = typeof emailFrom === 'string' ? emailFrom.trim() : '';
if (configured) return configured;
const host = new URL(origin).hostname;
if (host.startsWith('[')) return `signin@[IPv6:${host.slice(1, -1)}]`;
if (/^[\d.]+$/.test(host)) return `signin@[${host}]`;
return `signin@${host}`;
}

/** Better Auth requires an email column, even for a provider-only account.
* This reserved address identifies an account; it is never a delivery address.
* Include the client ID because provider subjects can be scoped to an app.
Expand Down
5 changes: 3 additions & 2 deletions packages/auth/src/better-auth-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
email: (env: E) => EmailSender;
/**
* The default sign-in email's sender, usually read from a binding such as
* `(env) => env.EMAIL_FROM`. When omitted, `undefined` or blank, it is
* `signin@<hostname of APP_ORIGIN>`. `renderEmail` receives it as `from`.
* `(env) => env.EMAIL_FROM`, and trimmed. When omitted, `undefined` or
* blank, it is `signin@<hostname of APP_ORIGIN>`, as `emailFrom` describes
* for `createAuthApp`. `renderEmail` receives it as `from`.
*/
emailFrom?: string | ((env: E) => string);
/** Builds this request's hooks; their pending work is kept alive with `waitUntil`. */
Expand Down
22 changes: 13 additions & 9 deletions packages/auth/src/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
identityEmail,
isIdentityEmail,
providerSubject,
signInSender,
} from './better-auth-email.ts';

/** How a login proved identity: an email code, or a provider's ID. */
Expand Down Expand Up @@ -62,7 +63,10 @@ export interface SignInEmailInput {
expiresInMinutes: number;
/** `AuthOptions.appName`, when set. Escape it with `escapeHtml` before placing it in HTML. */
appName?: string;
/** The resolved sender: `emailFrom`, or `signin@<hostname of origin>`. */
/**
* The resolved sender: `emailFrom` trimmed, or `signin@<hostname of origin>`
* (`signin@[<IPv4>]` or `signin@[IPv6:<address>]` for an IP origin).
*/
from: string;
}

Expand Down Expand Up @@ -110,9 +114,7 @@ async function sendSignInEmail(
otp,
expiresInMinutes: otpExpiresInSeconds / 60,
...(options.appName ? { appName: options.appName } : {}),
from: options.emailFrom?.trim()
? options.emailFrom
: `signin@${new URL(options.origin).hostname}`,
from: signInSender(options.emailFrom, options.origin),
};
message = await (options.renderEmail ?? defaultSignInEmail)(input);
if (!addressedOnlyTo(message, email))
Expand Down Expand Up @@ -164,11 +166,13 @@ export interface AuthOptions {
appName?: string;
/**
* The sender of the default sign-in email, and `from` in `renderEmail`'s
* input. Defaults to `signin@<hostname of origin>`, so a deployment that
* forgets it is refused by its mail provider rather than sending as another
* domain. A blank string counts as omitted, so a preview that blanks its
* mail bindings keeps working. `postmarkEmail`'s own `from` argument
* overrides any message sender.
* input. Defaults to `signin@<hostname of origin>`, or an address literal
* such as `signin@[127.0.0.1]` or `signin@[IPv6:::1]` for an IP origin, so a
* deployment that forgets it is refused by its mail provider rather than
* sending as another domain. Surrounding whitespace is trimmed, and a blank
* string counts as omitted, so a preview that blanks its mail bindings keeps
* working. `postmarkEmail`'s own `from` argument overrides any message
* sender.
*/
emailFrom?: string;
/**
Expand Down
10 changes: 8 additions & 2 deletions packages/pgstencil/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
{
"name": "pgstencil",
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./database": "./src/database.ts",
"./snapshots": "./src/snapshots.ts",
"./testing": "./src/testing.ts",
"./postgres": "./src/postgres.ts",
"./diagnostics": "./src/diagnostics.ts"
"./diagnostics": "./src/diagnostics.ts",
"./email": "./src/email.ts"
},
"bin": {
"pgstencil": "./src/cli.ts"
Expand Down Expand Up @@ -83,6 +85,10 @@
"./diagnostics": {
"types": "./dist/diagnostics.d.ts",
"import": "./dist/diagnostics.js"
},
"./email": {
"types": "./dist/email.d.ts",
"import": "./dist/email.js"
}
},
"bin": {
Expand Down
Loading
Loading