create-justscale 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.
@@ -0,0 +1,185 @@
1
+ ---
2
+ name: multi-instance-test
3
+ description: Scaffold a JustScale multi-instance e2e test that spawns two real Node worker processes coordinating through a shared Postgres + Redis. Verifies cross-instance invariants — lock mutual exclusion, signal NOTIFY routing, channel delivery, process resumption after crash. Use when testing distributed primitives, NEVER for single-node logic.
4
+ ---
5
+
6
+ # Skill: multi-instance-test
7
+
8
+ Scaffold an e2e test that spawns TWO real worker processes and asserts an
9
+ invariant holds across them. The canonical pattern lives at
10
+ `packages/misc/e2e/test/mixed-adapter-multi-process.e2e.test.ts`. Read it
11
+ before extending it.
12
+
13
+ ## Why two real processes
14
+
15
+ JustScale's distributed primitives — locks, channels, signals, durable
16
+ processes — are meaningless under a single Node process. A single-process
17
+ test cannot catch:
18
+
19
+ - Two nodes both believing they hold the same lock.
20
+ - A signal `NOTIFY` routing to the wrong process replica.
21
+ - A channel publish that the second subscriber misses.
22
+ - A durable process suspending on instance A and resuming on instance B.
23
+
24
+ For these primitives the multi-process test IS the test. Memory rule from
25
+ this repo: **don't fake the second instance with a mock or a second
26
+ builder in the same process**. The whole point is two real OS processes
27
+ coordinating through a real shared backend.
28
+
29
+ ## Requirements
30
+
31
+ - Docker Postgres on port `5433` (default `PG_URL=postgres://justscale:justscale@localhost:5433/postgres`).
32
+ - Docker Redis on port `6380` (default `REDIS_URL=redis://localhost:6380`).
33
+ - The test should `before(...)` probe both and **skip with a clear message** if either is unreachable. Don't fail — let CI decide.
34
+
35
+ ## Two-file pattern
36
+
37
+ A multi-instance test is always two files in the same folder:
38
+
39
+ 1. **`<scenario>/worker.ts`** — the entrypoint each spawned process runs.
40
+ Reads its wiring from env vars, builds the app, calls `listen()`,
41
+ prints `READY <port>` to stdout once `app.ready` resolves.
42
+ 2. **`<scenario>.e2e.test.ts`** — the driver. Spawns workers via
43
+ `child_process.spawn`, talks to them over HTTP, asserts.
44
+
45
+ ## Worker template
46
+
47
+ ```typescript
48
+ // <scenario>/worker.ts
49
+ import { listen } from '@justscale/http';
50
+ import { makeApp } from './app.js';
51
+
52
+ async function main() {
53
+ const port = Number(process.env.PORT);
54
+ const pgUrl = process.env.PG_URL!;
55
+ const redisUrl = process.env.REDIS_URL!;
56
+ const lockBackend = process.env.LOCK_BACKEND as 'pg-advisory' | 'redis';
57
+ const channelBackend = process.env.CHANNEL_BACKEND as 'pg' | 'redis' | 'memory';
58
+ const instanceId = process.env.INSTANCE_ID ?? 'X';
59
+ const prefix = process.env.CHANNEL_PREFIX!;
60
+
61
+ const { builder } = makeApp({ port, pgUrl, redisUrl, lockBackend, channelBackend, instanceId, prefix });
62
+ const built = builder.build();
63
+ const app = built.compile();
64
+ await app.ready;
65
+
66
+ const server = listen(app, port);
67
+ await new Promise<void>((resolve, reject) => {
68
+ server.once('listening', resolve);
69
+ server.once('error', reject);
70
+ });
71
+
72
+ // Stdout liveness signal — the driver waits for this line.
73
+ console.log(`READY ${port}`);
74
+ }
75
+
76
+ main().catch((err) => {
77
+ console.error(err);
78
+ process.exit(1);
79
+ });
80
+ ```
81
+
82
+ ## Driver template
83
+
84
+ ```typescript
85
+ // <scenario>.e2e.test.ts
86
+ import { describe, it, before, after } from 'node:test';
87
+ import assert from 'node:assert/strict';
88
+ import { spawn, type ChildProcess } from 'node:child_process';
89
+ import { once } from 'node:events';
90
+ import path from 'node:path';
91
+ import { fileURLToPath } from 'node:url';
92
+
93
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
94
+ const WORKER = path.join(__dirname, '<scenario>', 'worker.ts');
95
+ const TSX = path.join(__dirname, '..', 'node_modules', '.bin', 'tsx');
96
+
97
+ const PG_URL = process.env.PG_URL ?? 'postgres://justscale:justscale@localhost:5433/postgres';
98
+ const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6380';
99
+
100
+ async function spawnWorker(opts: { port: number; instanceId: 'A' | 'B'; prefix: string }): Promise<ChildProcess> {
101
+ const child = spawn(process.execPath, [TSX, WORKER], {
102
+ env: {
103
+ ...process.env,
104
+ PORT: String(opts.port),
105
+ INSTANCE_ID: opts.instanceId,
106
+ CHANNEL_PREFIX: opts.prefix,
107
+ PG_URL,
108
+ REDIS_URL,
109
+ LOCK_BACKEND: 'pg-advisory',
110
+ CHANNEL_BACKEND: 'pg',
111
+ JUSTSCALE_NO_SOCKET: '1', // no cluster socket — coordinate purely through pg/redis
112
+ },
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ });
115
+
116
+ // Wait for the READY <port> liveness line on stdout
117
+ await new Promise<void>((resolve, reject) => {
118
+ child.stdout!.on('data', (chunk: Buffer) => {
119
+ if (chunk.toString().includes(`READY ${opts.port}`)) resolve();
120
+ });
121
+ child.once('exit', (code) => reject(new Error(`worker exited early (${code})`)));
122
+ setTimeout(() => reject(new Error('worker boot timeout')), 15000);
123
+ });
124
+
125
+ return child;
126
+ }
127
+
128
+ describe('<scenario>', () => {
129
+ let workerA: ChildProcess;
130
+ let workerB: ChildProcess;
131
+
132
+ before(async () => {
133
+ // TODO: probe pg + redis liveness; skip if either is unreachable.
134
+ const prefix = `<scenario>_${Date.now()}`;
135
+ workerA = await spawnWorker({ port: 6401, instanceId: 'A', prefix });
136
+ workerB = await spawnWorker({ port: 6402, instanceId: 'B', prefix });
137
+ });
138
+
139
+ after(async () => {
140
+ workerA?.kill('SIGTERM');
141
+ workerB?.kill('SIGTERM');
142
+ await Promise.all([
143
+ workerA && once(workerA, 'exit'),
144
+ workerB && once(workerB, 'exit'),
145
+ ]);
146
+ });
147
+
148
+ it('invariant holds across two instances', async () => {
149
+ // 1. Trigger the action on workerA via HTTP (port 6401).
150
+ // 2. Assert workerB observes the consequence (HTTP poll on 6402, or DB read).
151
+ // 3. Assert no double-execution.
152
+ });
153
+ });
154
+ ```
155
+
156
+ ## Before scaffolding, ask
157
+
158
+ 1. **Invariant** — what should be true after the trigger? ("exactly one
159
+ process resumed", "B observes the channel publish", "only one of A/B
160
+ holds the lock at any moment".)
161
+ 2. **Trigger** — what HTTP call on A produces the observable on B?
162
+ 3. **Adapter set** — `pg-advisory` + `pg`, `redis` + `redis`, or mixed.
163
+ Multi-adapter coverage is the whole point of `packages/misc/e2e/`.
164
+
165
+ ## Anti-patterns
166
+
167
+ - **Don't** put both apps in one Node process via `JustScale().build()`
168
+ twice. They'd share memory and module-level state — every distributed
169
+ bug invisible.
170
+ - **Don't** mock the shared backend. The point is real pg/redis under
171
+ contention.
172
+ - **Don't** rely on `app.serve({ socketPath })`. The cluster socket is for
173
+ CLI ↔ app, not for two workers to find each other. Set
174
+ `JUSTSCALE_NO_SOCKET=1` and coordinate through the chosen distributed
175
+ adapters.
176
+
177
+ ## After scaffolding
178
+
179
+ - Print both file paths.
180
+ - Remind the user: run with `tsx --test <driver>`. Don't add to the
181
+ package's `test` script until it passes twice clean — leaked workers
182
+ between runs are the most common failure mode.
183
+ - Read the canonical e2e (`mixed-adapter-multi-process.e2e.test.ts`) for
184
+ details on liveness probes, port collision avoidance, and adapter-matrix
185
+ parameterization.
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: new-process
3
+ description: Scaffold a new JustScale durable process — generates `<name>.signals.ts` and `<name>.process.ts` matching this repo's idiom. Forces `.types({...})` on every signal path param, `Locked<T>` mutators, no string IDs, imports only from `@justscale/core/process` and `@justscale/core/models`. Trigger when the user asks to create a process, durable workflow, saga, or signal-driven flow.
4
+ ---
5
+
6
+ # Skill: new-process
7
+
8
+ Scaffold a new durable process. Match the simple-app idiom — every signal
9
+ path param `.types()`d, every mutator `Locked<T>`, no string IDs leaking
10
+ through.
11
+
12
+ ## Usage
13
+
14
+ `/new-process <name> [Model]`
15
+
16
+ Example: `/new-process orderFulfillment Order`
17
+
18
+ If the user didn't specify a Model or a domain folder, ask once.
19
+ Placing files in the wrong domain folder is harder to fix than asking.
20
+
21
+ ## What to generate
22
+
23
+ Two files in the same domain folder as the related Model:
24
+
25
+ 1. `<name>.signals.ts` — `defineSignals(...)`
26
+ 2. `<name>.process.ts` — `createProcess(...)`
27
+
28
+ ## Rules — non-negotiable
29
+
30
+ The framework enforces these at compile time. Writing the file correctly
31
+ the first time is faster than chasing type errors.
32
+
33
+ 1. **Every signal path parameter MUST be `.types({...})`d.** The path is
34
+ the topic on the cluster bus; typed params are the routing key. Two
35
+ forms are valid:
36
+ - Explicit: `.types({ cart: Cart })` — for path `/cart/:cart/...`
37
+ - Lowercased shorthand: `.types({ Cart })` — also for `:cart`
38
+ Prefer the explicit form. It's what `examples/simple-app/` uses and
39
+ it's unambiguous when param names don't match model names.
40
+ 2. **Service mutators take `Locked<T>`** — never `Ref<T>` or string ID.
41
+ If a method changes state, its signature must declare the lock.
42
+ 3. **Path parameter names match the model token in `.types({...})`.** A
43
+ mismatch is a compile error.
44
+ 4. **`signal.data<T>` is for non-routable payload only.** Anything that
45
+ identifies an entity goes in the path with `.types`, never in `.data`.
46
+ 5. **No string IDs in the process file.** If a `Locked<T>` isn't already
47
+ in scope from the signal payload, the handler `await`s the `Ref` to
48
+ materialise a `Persistent`.
49
+ 6. **Imports come from `@justscale/core/process` and
50
+ `@justscale/core/models` only.** Never reach into infra packages
51
+ (`@justscale/postgres`, `@justscale/redis`) from a process file.
52
+
53
+ ## Template
54
+
55
+ `<name>.signals.ts`:
56
+
57
+ ```typescript
58
+ import { defineSignals } from '@justscale/core/process';
59
+ import { <Model> } from './<model>.model.js';
60
+
61
+ export class <Name>Signals extends defineSignals((signal) => ({
62
+ <eventName>: signal('/<root>/:<param>/<verb>')
63
+ .data<{ /* optional non-routable payload */ }>()
64
+ .types({ <param>: <Model> }),
65
+ })) {}
66
+ ```
67
+
68
+ `<name>.process.ts`:
69
+
70
+ ```typescript
71
+ import { createProcess, signal, race, delay } from '@justscale/core/process';
72
+ import { <Model> } from './<model>.model.js';
73
+ import { <Name>Signals } from './<name>.signals.js';
74
+
75
+ export const <name> = createProcess({
76
+ path: '/<root>/:<param>/<verb>',
77
+ types: { <param>: <Model> },
78
+ inject: { signals: <Name>Signals },
79
+
80
+ async handler({ signals }, { <param> }) {
81
+ const r = race();
82
+ switch (true) {
83
+ case signal(r, signals.<eventName>):
84
+ // r.<param> is Locked<<Model>> — the signal carried the locked entity.
85
+ return { status: 'done' as const };
86
+ case delay.days(r, 3):
87
+ return { status: 'timeout' as const };
88
+ }
89
+ },
90
+ });
91
+ ```
92
+
93
+ ## Reference
94
+
95
+ The canonical examples are:
96
+
97
+ - `examples/simple-app/src/domains/cart/cart.signals.ts` — explicit
98
+ `.types({ cart: Cart })` form, `.data<{...}>()` payloads.
99
+ - `examples/simple-app/src/domains/cart/cart-lifecycle.process.ts` —
100
+ `while (true) { race + signal/delay switch }` shape.
101
+
102
+ When in doubt, copy the structure of those files.
103
+
104
+ ## After generating
105
+
106
+ - Print both file paths.
107
+ - Remind the user to register the signal class and the process in the
108
+ domain's `.feature.ts` (or `app.ts` for tiny projects):
109
+ - `.add(<Name>Signals)`
110
+ - `.add(<name>)`
111
+ - Do NOT modify `app.ts` or `*.feature.ts` automatically. Bootstrap
112
+ edits cause merge churn — let the user wire it.
113
+
114
+ ## When NOT to use this skill
115
+
116
+ - Plain async helpers that don't need to suspend → write a
117
+ `defineService`, not a process.
118
+ - One-shot HTTP handlers → use a controller route.
119
+ - Cron-style schedules → use the scheduled-task primitive.