sparrowhawk-labs/nawate
Composer 安装命令:
composer require sparrowhawk-labs/nawate
包简介
Nawate — signed-URL state switching for manual demo/verification in Laravel apps. Recipe-based state, per-session SQLite isolation, zero host-app pollution.
README 文档
README
Signed-URL state switching for manual demo/verification in Laravel apps. Hit a link, land on the target screen already in the exact state you asked for — zero pollution of host app code.
Stack
- PHP 8.2+ / Laravel 12+
- SQLite — required for the demo/QA environment nawate runs in, regardless of what your production database is. See "Requirements" below before assuming this fits your app.
Requirements
nawate's isolation trick (instant, cheap, per-session copies) works by
literally copy()-ing a SQLite file and repointing database.default at the
copy. That means while a nawate demo session is active, the whole app runs
against a SQLite connection — not whatever your production database engine
is.
- Your production database can be MySQL, PostgreSQL, anything — nawate
never touches it (
enableddefaults tofalse, and even when on, it only swaps the connection during an active demo session). - Your demo/QA environment's schema and queries must run correctly on
SQLite. Ordinary Eloquent + standard migrations usually do (this is the
same reason Laravel's own test tooling defaults to SQLite). Code that
relies on engine-specific SQL — MySQL JSON operators,
FULLTEXTsearch, stored procedures, engine-specific collations — will not work through nawate as-is. - There is currently no MySQL/PostgreSQL-native provisioning backend (e.g. clone-database-and-switch-connection). If your demo data path genuinely can't run on SQLite, nawate isn't a fit yet.
Installation
composer require sparrowhawk-labs/nawate php artisan nawate:install
nawate:install publishes config + runs migrations + publishes docs, in two
layers meant to make nawate legible to LLM coding agents as well as humans:
- Core layer — the essentials (API, ordering rules, footguns) are
appended directly into the host app's
AGENTS.md(created if missing). If the host app has aCLAUDE.md, an@AGENTS.mdimport line is ensured there too, so Claude Code picks up the same core section without duplicating it (Claude Code readsCLAUDE.md, notAGENTS.md, unless it's imported this way — other AGENTS.md-convention tools readAGENTS.mddirectly). - Reference layer — this file, published as
docs/nawate/README.mdin the host app. Everything below "Status" is the reference layer: full API, config, internals, fragment design patterns, and troubleshooting.
Both steps are idempotent (safe to re-run nawate:install) and can be
skipped with --no-docs.
Status
Phase 1〜5 complete (package skeleton, state recipe / DB isolation core,
signed-URL layer, cleanup command, demo-app verification). See PLAN.md in
this repository for the full development log and verification history.
Quick start
// app/Providers/AppServiceProvider.php use SparrowhawkLabs\Nawate\Facades\Nawate; use Database\Seeders\UserSeeder; use Database\Seeders\PurchaseSeeder; public function boot(): void { // Fragment order in a link's recipe must respect FK dependencies — // see "Fragment design" below. Nawate::fragment('user:new', fn () => UserSeeder::asNewUser()); Nawate::fragment('user:repeat', fn () => UserSeeder::asRepeatCustomer()); Nawate::fragment('purchase:completed', fn () => PurchaseSeeder::afterCompleted()); }
// wherever you build the link to hand someone (a route, a controller, tinker, …) use SparrowhawkLabs\Nawate\Facades\Nawate; $url = Nawate::link( fragments: ['user:repeat', 'purchase:completed'], redirectTo: '/shop', userId: UserSeeder::DEMO_USER_ID, // logs this user in after provisioning );
Hitting $url:
- Provisions a fresh, isolated SQLite copy of your template DB.
- Runs each fragment's closure against that copy, in order.
- Logs in as
$userIdif given. - Redirects to
$redirectTo, with a cookie binding this browser to this demo session for the rest of its lifetime.
Config reference (config/nawate.php)
| Key | Env var | Default | Meaning |
|---|---|---|---|
enabled |
NAWATE_ENABLED |
false |
Master switch. No route, no middleware, no exception handling registered at all unless true. Set only in local/staging/demo. |
signed_url_ttl |
NAWATE_SIGNED_URL_TTL |
60 |
Minutes a Nawate::link() URL stays valid before Laravel's signed-URL check rejects it. |
demo_db_storage_path |
NAWATE_DEMO_DB_STORAGE_PATH |
app/nawate/demo-sessions |
Where per-session SQLite copies are written. Relative to storage_path() unless absolute. |
template_db_path |
NAWATE_TEMPLATE_DB_PATH |
(none — required) | Absolute path to a migrated, empty-of-demo-data SQLite file. You own keeping this in sync with your schema (see below). |
connection |
NAWATE_CONNECTION |
nawate_demo |
The DB connection name nawate registers and repoints at each session's file. Kept out of your own connection names — fragments/Seeders never reference it. |
cleanup_after_hours |
NAWATE_CLEANUP_AFTER_HOURS |
24 |
Sessions older than this are eligible for nawate:cleanup. |
Preparing the template DB
nawate does not own your migrations — it copies a file you prepare:
# after `php artisan migrate` against a normal empty DB (sqlite example)
cp database/database.sqlite storage/app/nawate/template.sqlite
Re-copy it any time your schema changes. There's no automatic drift detection — this is a manual step, documented here rather than automated, since "automatically re-migrate a file in production" is exactly the kind of implicit magic nawate avoids.
Fragment design
A fragment is a name plus a closure (Nawate::fragment($name, $callback))
that mutates the demo DB — typically by delegating to your own Seeder's
static methods. Nawate never decides what a fragment does; it only decides
when (during provisioning, against the isolated copy) and in what order
(the order given in a recipe's $fragments array).
Two rules follow directly from that design:
-
Order fragments to respect foreign keys. If
cart:with_itemsinserts a row withuser_id, whatever fragment creates that user must be listed first in the recipe:['user:repeat', 'cart:with_items'], not the reverse. -
Use a fixed, deterministic ID for any user you intend to log in as. Every demo session starts from a fresh copy of the template DB, so an auto-increment ID is only predictable if you force it:
class UserSeeder { public const DEMO_USER_ID = 1; public static function asNewUser(): User { return User::updateOrCreate( ['id' => self::DEMO_USER_ID], ['name' => '…', 'email' => '…', 'password' => Hash::make('…')], ); } }
Nawate::link(fragments: [...], redirectTo: '/shop', userId: UserSeeder::DEMO_USER_ID)then reliably logs in as the user the fragment just created, because the ID is forced rather than left to auto-increment.
A worked example of this pattern (3-axis EC demo — user type × purchase
status × cart contents) lives in the sibling nawate-demo-app project built
during Phase 5 verification.
How it works (internals)
- Signed link → recipe.
Nawate::link()doesn't look anything up server-side; it encodes the whole recipe (fragment names,$userId,$redirectTo) as the token itself (SparrowhawkLabs\Nawate\Support\StateRecipe::toToken()/fromToken()), then wraps it in a LaraveltemporarySignedRoute. Laravel's own HMAC-over-the-URL is what makes tampering detectable — the token's opacity is not a security boundary by itself. - Provisioning. Hitting the link (
NawateStateController) copiestemplate_db_pathto a new UUID-named file underdemo_db_storage_path, points thenawate.connection(defaultnawate_demo) at that copy, runs the recipe's fragments against it, then records anawate_demo_sessionsrow (uuid, recipe label, file path,expires_at) — all viaSparrowhawkLabs\Nawate\Services\DemoSessionManager. - The connection switch itself.
DB::purge($connection)+config(['database.connections.<connection>.database' => $path])+config(['database.default' => $connection]), restored to whatever was default before once fragments finish running. This was verified (Phase 2, seePLAN.mdandspike/) against both a bareIlluminate\Database\Capsuleand a full HTTP request cycle through a real Laravel Kernel, across 6 consecutive switches in the same process (a simplified stand-in for Octane-style persistent workers). Host app code never needs to reference the connection name. - Staying switched across requests. The controller sets a
nawate_sessioncookie holding the session UUID. ASwitchDemoConnectionmiddleware (pushed onto thewebgroup, only whilenawate.enabled) reads that cookie on every subsequent request and re-activates the same session's connection — so a redirect chain, or simply browsing around after landing, keeps seeing the demo data instead of snapping back to the host app's real DB. - Expired/tampered links. Laravel's
InvalidSignatureExceptionis caught and replaced, scoped only to thenawate.stateroute, with a plain Japanese explanation page (403) instead of the framework default.
The session-driver gotcha
If the host app's SESSION_DRIVER is database, the session table lookup
happens through config('database.default') at whatever point in the
middleware pipeline StartSession runs. Because SwitchDemoConnection is
appended to the end of the web group, a request can start reading its
session against the host's real DB and finish writing it against the demo
DB (or vice-versa on the next request) — the two can desync across
requests. This was found empirically during Phase 5's nawate-demo-app
verification. Fix: use a non-DB session driver (file is the simplest)
in every environment where nawate.enabled is true. Nawate itself does not
enforce or auto-switch this — it's a host-app config choice.
Known unverified edges
Carried over from the original design risk log (PLAN.md), still open:
- Behavior inside queued jobs (a job dispatched while the demo connection is active, then processed by a separate worker process). Nawate targets ordinary synchronous request/response demo/QA — job queues are out of scope for now.
- A real Octane (persistent-worker) deployment. The connection-switch mechanism was verified against a simplified multi-switch simulation in the same process, not an actual Octane server.
Cleanup
Expired demo sessions (SQLite copy + nawate_demo_sessions row) are removed by:
php artisan nawate:cleanup # deletes what's past expires_at php artisan nawate:cleanup --dry-run # lists what would be removed, deletes nothing
Nothing runs this automatically — wire it into your own schedule. Two common ways:
// bootstrap/app.php (or routes/console.php) — host app's own Schedule use Illuminate\Support\Facades\Schedule; Schedule::command('nawate:cleanup')->hourly();
# or, outside the app entirely (launchd/cron), if you'd rather not touch the host's schedule: 0 * * * * cd /path/to/app && php artisan nawate:cleanup >> storage/logs/nawate-cleanup.log 2>&1
Safety
No route is registered at all in production unless NAWATE_ENABLED is
explicitly set to true (disabled by default, opt-in only). Signed URLs otherwise carry no
authentication of their own — anyone who obtains a link's URL can use it
(impersonation risk is the tradeoff for zero host-app-code integration).
Treat links as sensitive as the state they grant, and keep signed_url_ttl
tight in anything reachable outside a trusted network.
Localization
The expired/tampered-link 403 page follows the host app's own locale —
app()->getLocale(), i.e. config('app.locale') / APP_LOCALE — the same
setting that drives every other Laravel translation. English and Japanese
are shipped (resources/lang/{en,ja}/messages.php); an unset or unshipped
locale falls back to config('app.fallback_locale') per normal Laravel
behavior. To add a language or override wording:
php artisan vendor:publish --tag=nawate-lang
# edit lang/vendor/nawate/{locale}/messages.php
Troubleshooting
SQLSTATE[23000]: … FOREIGN KEY constraint failedduring provisioning — a fragment referencing a not-yet-created row ran before the fragment that creates it. Fix the order in the recipe's$fragmentsarray (see "Fragment design" above).- Logged in as the wrong user, or not logged in at all, after a link with
userIdset — the ID doesn't match what the fragment actually created. Force a deterministic ID in the Seeder rather than relying on auto-increment (see "Fragment design" above). - Login doesn't stick past the first request / random logouts while
demoing — check
SESSION_DRIVER; see "The session-driver gotcha" above. - Link 403s immediately — either past
signed_url_ttlor the URL was edited/truncated (breaks the HMAC signature). Generate a new link. FragmentExecutionException: … threw while running against the demo SQLite connection— the wrapped original error names the real cause. If it also says "looks like a MySQL/PostgreSQL-specific SQL feature", a fragment called something SQLite doesn't implement (RAND(),MATCH/AGAINSTFULLTEXT search, a JSON operator, a stored procedureCALL, …) — see "Requirements" above; that fragment's query needs a SQLite-compatible rewrite (e.g.RAND()→RANDOM()) to run through nawate at all. Without that specific signature, treat it as an ordinary bug in the fragment — the exception'sgetPrevious()has the original exception if you need to inspect it further.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-06