gatefit 0.0.0-stage → 0.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable Gatefit changes are listed here. The heading version matches `package.json`. Tag `v0.4.0` is not cut in this change.
4
+
5
+ ## [0.4.0] - 2026-10-16
6
+
7
+ Tag-ready pack. npm `gatefit@0.4.0` is live (`npx gatefit`, https://www.npmjs.com/package/gatefit). The docs site is live at https://gatefit.io. Polar checkout is the committed Gatefit Indie License link.
8
+
9
+ ### Added
10
+
11
+ - Publishable `gatefit@0.4.0` CLI (`bin/gatefit.js`) and composite GitHub Action.
12
+ - Callers, once a human tags the release commit: `uses: gatefit/gatefit@v0.4.0` and `uses: gatefit/gatefit/.github/actions/gatefit@v0.4.0`.
13
+ - Fixture goldens for paid, cancel, refund, trial, past_due, duplicate delivery, seats, cancel-at-period-end, upgrade race, dispute, and unpaid retries.
14
+ - Starlight docs app under `site/` and GHA-free `scripts/deploy-site.sh`.
15
+ - `npm publish --dry-run` inside `./ci-local.sh`, with tarball checks that the bin is present and `site/` and secrets are absent.
16
+ - Pro pack license gate. `GATEFIT_LICENSE_KEY` is checked with Polar's public validate endpoint. Free packs do not call it. Chaos mode is not in this release.
17
+
18
+ ### Not in this version
19
+
20
+ - A docs deploy from this changelog. The site is already live at https://gatefit.io.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 edipert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # gatefit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions.
4
+
5
+ Fixture-first S1–S6 goldens, an `npx gatefit` CLI, and a GitHub Action. Node 22+. Not a billing UI.
6
+
7
+ ## Distribution
8
+
9
+ The CLI and GitHub Action in this repository stay free. The docs site is live. npm is live: `npx gatefit` ([gatefit](https://www.npmjs.com/package/gatefit)). Polar checkout is.
10
+
11
+ - **Docs site:** live at https://gatefit.io. Cloudflare Pages project `gatefit`, hostname `gatefit-bdu.pages.dev`. From `site/`: `npm run build`, then `npx wrangler@4 pages deploy dist --project-name gatefit`. `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` come from the environment. GitHub Pages needs a public repository or a paid plan and is not the live host. `scripts/deploy-site.sh` can copy `site/dist` locally or deploy that Cloudflare target.
12
+ - **npm:** live. `npx gatefit` is `gatefit@0.4.0` at https://www.npmjs.com/package/gatefit. CI runs `npm publish --dry-run` only.
13
+ - **Polar checkout:** live. Gatefit Indie License is **$49** one-time at `https://buy.polar.sh/polar_cl_biyvx7HYtN2u19tUJCCkIuGOc98uVv7IG3gcB04eIJ0`. A plain `site` build and `scripts/deploy-site.sh` use that committed link. `PUBLIC_POLAR_CHECKOUT_URL` may override it. Any other value fails closed.
14
+
15
+ ## Install / run
16
+
17
+ ```bash
18
+ npx gatefit --version
19
+ npx gatefit adapter-check
20
+ npx gatefit run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
21
+ ```
22
+
23
+ `gatefit@0.4.0`: https://www.npmjs.com/package/gatefit
24
+
25
+ From a clone:
26
+
27
+ ```bash
28
+ npm install
29
+ node bin/gatefit.js --version
30
+ node bin/gatefit.js adapter-check
31
+ node bin/gatefit.js run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
32
+ ```
33
+
34
+ Named verify gate (fixture-first; do not set `GATEFIT_LIVE_CLOCK`):
35
+
36
+ ```bash
37
+ bash ./ci-local.sh
38
+ ```
39
+
40
+ Bring your own AccessAdapter in ≤30 minutes: [docs/bring-your-adapter.md](docs/bring-your-adapter.md).
41
+
42
+ ## GitHub Action
43
+
44
+ Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.4.0` is not cut yet. After a human tags the release commit:
45
+
46
+ ```yaml
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: "22"
50
+ - uses: gatefit/gatefit@v0.4.0
51
+ with:
52
+ adapter: src/your-access-adapter.ts
53
+ ```
54
+
55
+ Nested path still works:
56
+
57
+ ```yaml
58
+ - uses: gatefit/gatefit/.github/actions/gatefit@v0.4.0
59
+ with:
60
+ adapter: src/your-access-adapter.ts
61
+ ```
62
+
63
+ The Action wraps `gatefit run`. Adapter path is in *your* repo; goldens default to Gatefit’s S1–S6 pack. See [docs/publish.md](docs/publish.md) and the release draft [docs/release-v0.4.0.md](docs/release-v0.4.0.md).
64
+
65
+ Do not set `GATEFIT_LIVE_CLOCK` for the named gate. Do not use `sk_live`. Never treat Stripe HTTP 200 as proof of access.
66
+
67
+ Opt-in live Test Clocks (not default CI): [docs/live-clock.md](docs/live-clock.md).
68
+
69
+ ## License
70
+
71
+ S1–S6, seats/races, dispute/unpaid, and live Test Clocks stay free. Those packs do not read `GATEFIT_LICENSE_KEY` and do not call Polar.
72
+
73
+ The $49 Gatefit Indie License unlocks Pro packs. Chaos mode is coming and is not in this release. Provider packs are not in this release. A missing, invalid, or revoked key exits non-zero and points at https://gatefit.io/#pricing.
74
+
75
+ ```bash
76
+ export GATEFIT_LICENSE_KEY='GATEFIT-…'
77
+ ```
78
+
79
+ Validation is `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` with the key and organization id `68556619-45ab-4a88-8ad8-08606c2b820e`. No Polar API token. A valid result is cached for 24 hours by sha256, never as the raw key. If Polar is unreachable, a cached valid result is reused with a warning. With no cache, the command warns and still runs. Details: [docs/license.md](docs/license.md).
package/action.yml ADDED
@@ -0,0 +1,23 @@
1
+ name: Gatefit
2
+ description: Run Gatefit S1–S6 entitlement goldens against an AccessAdapter (access DB, not Stripe HTTP 200)
3
+ author: gatefit
4
+ branding:
5
+ icon: shield
6
+ color: green
7
+ inputs:
8
+ adapter:
9
+ description: Path to the AccessAdapter module, relative to the caller workspace
10
+ required: true
11
+ suite:
12
+ description: Golden file, directory, or id. Defaults to bundled S1–S6 goldens
13
+ required: false
14
+ default: ""
15
+ runs:
16
+ using: composite
17
+ steps:
18
+ - name: Run gatefit
19
+ shell: bash
20
+ env:
21
+ GATEFIT_ADAPTER_INPUT: ${{ inputs.adapter }}
22
+ GATEFIT_SUITE_INPUT: ${{ inputs.suite }}
23
+ run: bash "${GITHUB_ACTION_PATH}/scripts/action-run.sh"
package/bin/gatefit.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const cli = path.join(root, "src/cli.ts");
9
+ const requireFromPackage = createRequire(path.join(root, "package.json"));
10
+
11
+ function resolveTsxCli() {
12
+ try {
13
+ return path.join(path.dirname(requireFromPackage.resolve("tsx/package.json")), "dist/cli.mjs");
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ const tsxCli = resolveTsxCli();
20
+ if (!tsxCli) {
21
+ console.error("gatefit: missing tsx (run npm install so the gatefit package can resolve tsx)");
22
+ process.exit(1);
23
+ }
24
+
25
+ process.env.GATEFIT_PACKAGE_ROOT ??= root;
26
+ process.env.INIT_CWD ??= process.cwd();
27
+ const result = spawnSync(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], {
28
+ stdio: "inherit",
29
+ cwd: process.cwd(),
30
+ env: process.env,
31
+ });
32
+ process.exit(result.status === null ? 1 : result.status);
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "gatefit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "gatefit",
8
+ "bin": {
9
+ "gatefit": "./bin/gatefit.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "tests/entitlements/goldens",
15
+ "action.yml",
16
+ "scripts/action-run.sh",
17
+ "LICENSE",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "scripts": {
25
+ "gatefit": "tsx src/cli.ts",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "tsx --test --test-concurrency=1 tests/entitlements/*.test.ts"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/gatefit/gatefit.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/gatefit/gatefit/issues"
35
+ },
36
+ "homepage": "https://github.com/gatefit/gatefit#readme",
37
+ "keywords": [
38
+ "stripe",
39
+ "entitlements",
40
+ "access",
41
+ "ci",
42
+ "webhooks",
43
+ "billing"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "better-sqlite3": "^13.0.3",
50
+ "tsx": "^4.20.5",
51
+ "yaml": "^2.8.1"
52
+ },
53
+ "devDependencies": {
54
+ "@types/better-sqlite3": "^9.6.0",
55
+ "@types/node": "^22.18.0",
56
+ "typescript": "^5.9.2"
57
+ }
58
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ if [[ -f "${GITHUB_ACTION_PATH}/package.json" && -f "${GITHUB_ACTION_PATH}/bin/gatefit.js" ]]; then
4
+ GATEFIT_ROOT="${GITHUB_ACTION_PATH}"
5
+ else
6
+ GATEFIT_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)"
7
+ fi
8
+ ADAPTER="$(cd "${GITHUB_WORKSPACE}" && realpath "${GATEFIT_ADAPTER_INPUT}")"
9
+ if [[ -n "${GATEFIT_SUITE_INPUT}" && -e "${GITHUB_WORKSPACE}/${GATEFIT_SUITE_INPUT}" ]]; then
10
+ SUITE="$(cd "${GITHUB_WORKSPACE}" && realpath "${GATEFIT_SUITE_INPUT}")"
11
+ elif [[ -n "${GATEFIT_SUITE_INPUT}" ]]; then
12
+ SUITE="${GATEFIT_SUITE_INPUT}"
13
+ else
14
+ SUITE="${GATEFIT_ROOT}/tests/entitlements/goldens"
15
+ fi
16
+ cd "${GATEFIT_ROOT}"
17
+ npm install --ignore-scripts
18
+ node "${GATEFIT_ROOT}/bin/gatefit.js" run --suite "${SUITE}" --adapter "${ADAPTER}"
@@ -0,0 +1,88 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import fs from "node:fs";
3
+ import { resolveUserOrPackage } from "./paths.ts";
4
+
5
+ export type AccessSnapshot = {
6
+ locked: boolean;
7
+ plan?: string;
8
+ seats?: number;
9
+ features?: string[];
10
+ };
11
+
12
+ export type BillingEvent = {
13
+ id: string;
14
+ type: string;
15
+ customerId: string;
16
+ plan?: string;
17
+ seats?: number;
18
+ features?: string[];
19
+ paid?: boolean;
20
+ canceled?: boolean;
21
+ refunded?: boolean;
22
+ status?: string;
23
+ httpStatus?: number;
24
+ stripeHttpStatus?: number;
25
+ cancel_at_period_end?: boolean;
26
+ period_end?: number;
27
+ now?: number;
28
+ };
29
+
30
+ export type AccessAdapter = {
31
+ apply(event: BillingEvent): void | Promise<void>;
32
+ snapshot(customerId: string): AccessSnapshot | Promise<AccessSnapshot>;
33
+ reset?: () => void | Promise<void>;
34
+ };
35
+
36
+ export type CompareResult = {
37
+ ok: boolean;
38
+ mismatches: string[];
39
+ };
40
+
41
+ const LIVE_KEY = /\bsk_live(?:_[A-Za-z0-9_]*)?/;
42
+
43
+ export function assertNoLiveStripeSecret(haystack: string): void {
44
+ if (LIVE_KEY.test(haystack)) {
45
+ throw new Error("refuse sk_live: Gatefit will not load a live Stripe secret");
46
+ }
47
+ }
48
+
49
+ export function compareAccess(actual: AccessSnapshot, expected: AccessSnapshot): CompareResult {
50
+ const mismatches: string[] = [];
51
+ const keys = Object.keys(expected) as (keyof AccessSnapshot)[];
52
+ for (const key of keys) {
53
+ const want = expected[key];
54
+ const got = actual[key];
55
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
56
+ mismatches.push(`${String(key)}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
57
+ }
58
+ }
59
+ return { ok: mismatches.length === 0, mismatches };
60
+ }
61
+
62
+ export async function loadAdapter(spec: string): Promise<AccessAdapter> {
63
+ const resolved = resolveUserOrPackage(spec);
64
+ const source = fs.readFileSync(resolved, "utf8");
65
+ assertNoLiveStripeSecret(source);
66
+ assertNoLiveStripeSecret(JSON.stringify(process.env));
67
+ const mod = (await import(pathToFileURL(resolved).href)) as {
68
+ createAdapter?: () => AccessAdapter;
69
+ default?: AccessAdapter | (() => AccessAdapter);
70
+ adapter?: AccessAdapter;
71
+ };
72
+ const adapter = instantiateAdapter(mod);
73
+ if (!adapter || typeof adapter.apply !== "function" || typeof adapter.snapshot !== "function") {
74
+ throw new Error(`adapter ${spec} must export createAdapter() or AccessAdapter with apply + snapshot`);
75
+ }
76
+ return adapter;
77
+ }
78
+
79
+ function instantiateAdapter(mod: {
80
+ createAdapter?: () => AccessAdapter;
81
+ default?: AccessAdapter | (() => AccessAdapter);
82
+ adapter?: AccessAdapter;
83
+ }): AccessAdapter | undefined {
84
+ if (typeof mod.createAdapter === "function") return mod.createAdapter();
85
+ const candidate = mod.default ?? mod.adapter;
86
+ if (typeof candidate === "function") return candidate();
87
+ return candidate;
88
+ }
@@ -0,0 +1,104 @@
1
+ import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
2
+
3
+ export type AccessStore = {
4
+ rows: Map<string, AccessSnapshot>;
5
+ seen: Set<string>;
6
+ };
7
+
8
+ export function emptyStore(): AccessStore {
9
+ return { rows: new Map(), seen: new Set() };
10
+ }
11
+
12
+ export function isPaid(event: BillingEvent): boolean {
13
+ return event.type === "invoice.paid" || event.paid === true;
14
+ }
15
+
16
+ export function isImmediateCancel(event: BillingEvent): boolean {
17
+ return (
18
+ event.canceled === true ||
19
+ event.type === "customer.subscription.deleted" ||
20
+ event.type === "customer.subscription.canceled"
21
+ );
22
+ }
23
+
24
+ export function isCanceled(event: BillingEvent): boolean {
25
+ return isImmediateCancel(event);
26
+ }
27
+
28
+ export function isCapePeriodEnded(event: BillingEvent): boolean {
29
+ return (
30
+ event.cancel_at_period_end === true &&
31
+ typeof event.now === "number" &&
32
+ typeof event.period_end === "number" &&
33
+ event.now >= event.period_end
34
+ );
35
+ }
36
+
37
+ export function isDispute(event: BillingEvent): boolean {
38
+ return (
39
+ event.type === "charge.dispute.created" ||
40
+ event.type === "radar.early_fraud_warning.created"
41
+ );
42
+ }
43
+
44
+ export function isRefund(event: BillingEvent): boolean {
45
+ return event.refunded === true || event.type === "charge.refunded";
46
+ }
47
+
48
+ export function isUnpaidExhaust(event: BillingEvent): boolean {
49
+ return event.status === "unpaid" || event.type === "invoice.marked_uncollectible";
50
+ }
51
+
52
+ export function isPastDue(event: BillingEvent): boolean {
53
+ return event.status === "past_due" || event.type === "invoice.payment_failed";
54
+ }
55
+
56
+ export function isTrialing(event: BillingEvent): boolean {
57
+ return event.status === "trialing";
58
+ }
59
+
60
+ function withEntitlements(base: AccessSnapshot, event: BillingEvent): AccessSnapshot {
61
+ const next: AccessSnapshot = { ...base };
62
+ if (event.plan !== undefined) next.plan = event.plan;
63
+ if (event.seats !== undefined) next.seats = event.seats;
64
+ if (event.features !== undefined) next.features = event.features;
65
+ return next;
66
+ }
67
+
68
+ export function nextSnapshot(current: AccessSnapshot, event: BillingEvent): AccessSnapshot {
69
+ if (isDispute(event)) {
70
+ return { ...current, locked: true, features: [] };
71
+ }
72
+ if (
73
+ isRefund(event) ||
74
+ isImmediateCancel(event) ||
75
+ isPastDue(event) ||
76
+ isUnpaidExhaust(event) ||
77
+ isTrialing(event)
78
+ ) {
79
+ return { ...current, locked: true };
80
+ }
81
+ if (isCapePeriodEnded(event)) {
82
+ return { ...current, locked: true };
83
+ }
84
+ if (isPaid(event)) {
85
+ return withEntitlements({ ...current, locked: false }, event);
86
+ }
87
+ if (
88
+ event.type === "customer.subscription.updated" &&
89
+ (event.cancel_at_period_end === true ||
90
+ event.seats !== undefined ||
91
+ event.plan !== undefined ||
92
+ event.features !== undefined)
93
+ ) {
94
+ return withEntitlements(current, event);
95
+ }
96
+ return current;
97
+ }
98
+
99
+ export function applyBillingEvent(store: AccessStore, event: BillingEvent): void {
100
+ if (store.seen.has(event.id)) return;
101
+ store.seen.add(event.id);
102
+ const current = store.rows.get(event.customerId) ?? { locked: true };
103
+ store.rows.set(event.customerId, nextSnapshot(current, event));
104
+ }
@@ -0,0 +1,20 @@
1
+ import type { AccessAdapter } from "../access-adapter.ts";
2
+ import { applyBillingEvent, emptyStore } from "../access-rules.ts";
3
+
4
+ export function createAdapter(): AccessAdapter {
5
+ const store = emptyStore();
6
+ return {
7
+ apply(event) {
8
+ applyBillingEvent(store, event);
9
+ },
10
+ snapshot(customerId) {
11
+ return store.rows.get(customerId) ?? { locked: true };
12
+ },
13
+ reset() {
14
+ store.rows.clear();
15
+ store.seen.clear();
16
+ },
17
+ };
18
+ }
19
+
20
+ export default createAdapter;
@@ -0,0 +1,25 @@
1
+ import type { AccessAdapter, AccessSnapshot, BillingEvent } from "../access-adapter.ts";
2
+
3
+ function stripeLooksPaid(event: BillingEvent): boolean {
4
+ return event.httpStatus === 200 || event.stripeHttpStatus === 200;
5
+ }
6
+
7
+ export function createAdapter(): AccessAdapter {
8
+ const rows = new Map<string, AccessSnapshot>();
9
+ return {
10
+ apply(event: BillingEvent) {
11
+ // Anti-pattern: treat Stripe HTTP 200 as access proof.
12
+ if (stripeLooksPaid(event)) {
13
+ rows.set(event.customerId, { locked: false, plan: event.plan });
14
+ }
15
+ },
16
+ snapshot(customerId: string) {
17
+ return rows.get(customerId) ?? { locked: true };
18
+ },
19
+ reset() {
20
+ rows.clear();
21
+ },
22
+ };
23
+ }
24
+
25
+ export default createAdapter;
@@ -0,0 +1,83 @@
1
+ import { createRequire } from "node:module";
2
+ import type { AccessAdapter, AccessSnapshot, BillingEvent } from "../access-adapter.ts";
3
+ import { nextSnapshot } from "../access-rules.ts";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const Database = require("better-sqlite3") as typeof import("better-sqlite3");
7
+
8
+ type AccessRow = {
9
+ customer_id: string;
10
+ locked: number;
11
+ plan: string | null;
12
+ seats: number | null;
13
+ features: string | null;
14
+ };
15
+
16
+ function rowToSnapshot(row: AccessRow | undefined): AccessSnapshot {
17
+ if (!row) return { locked: true };
18
+ const snap: AccessSnapshot = { locked: row.locked === 1 };
19
+ if (row.plan != null) snap.plan = row.plan;
20
+ if (row.seats != null) snap.seats = row.seats;
21
+ if (row.features != null) snap.features = JSON.parse(row.features) as string[];
22
+ return snap;
23
+ }
24
+
25
+ export function createAdapter(): AccessAdapter {
26
+ const filename = process.env.GATEFIT_SQLITE_PATH ?? ":memory:";
27
+ const db = new Database(filename);
28
+ db.exec(`
29
+ CREATE TABLE IF NOT EXISTS access (
30
+ customer_id TEXT PRIMARY KEY,
31
+ locked INTEGER NOT NULL,
32
+ plan TEXT,
33
+ seats INTEGER,
34
+ features TEXT
35
+ );
36
+ CREATE TABLE IF NOT EXISTS seen_events (
37
+ event_id TEXT PRIMARY KEY
38
+ );
39
+ `);
40
+ const seenAdd = db.prepare("INSERT OR IGNORE INTO seen_events (event_id) VALUES (?)");
41
+ const accessGet = db.prepare(
42
+ "SELECT customer_id, locked, plan, seats, features FROM access WHERE customer_id = ?",
43
+ );
44
+ const accessUpsert = db.prepare(`
45
+ INSERT INTO access (customer_id, locked, plan, seats, features)
46
+ VALUES (@customer_id, @locked, @plan, @seats, @features)
47
+ ON CONFLICT(customer_id) DO UPDATE SET
48
+ locked = excluded.locked,
49
+ plan = excluded.plan,
50
+ seats = excluded.seats,
51
+ features = excluded.features
52
+ `);
53
+ const clearAccess = db.prepare("DELETE FROM access");
54
+ const clearSeen = db.prepare("DELETE FROM seen_events");
55
+ const applyEvent = db.transaction((event: BillingEvent) => {
56
+ const inserted = seenAdd.run(event.id);
57
+ if (inserted.changes === 0) return;
58
+ const current = rowToSnapshot(accessGet.get(event.customerId) as AccessRow | undefined);
59
+ const next = nextSnapshot(current, event);
60
+ accessUpsert.run({
61
+ customer_id: event.customerId,
62
+ locked: next.locked ? 1 : 0,
63
+ plan: next.plan ?? null,
64
+ seats: next.seats ?? null,
65
+ features: next.features ? JSON.stringify(next.features) : null,
66
+ });
67
+ });
68
+
69
+ return {
70
+ apply(event: BillingEvent) {
71
+ applyEvent(event);
72
+ },
73
+ snapshot(customerId: string) {
74
+ return rowToSnapshot(accessGet.get(customerId) as AccessRow | undefined);
75
+ },
76
+ reset() {
77
+ clearAccess.run();
78
+ clearSeen.run();
79
+ },
80
+ };
81
+ }
82
+
83
+ export default createAdapter;