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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/bin/create-justscale.js +3 -0
- package/dist/detect.d.ts +11 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +68 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +157 -0
- package/dist/index.js.map +1 -0
- package/dist/scaffold.d.ts +19 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +324 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +35 -0
- package/templates/skills/audit-domain-purity/SKILL.md +124 -0
- package/templates/skills/justscale-concepts/SKILL.md +191 -0
- package/templates/skills/multi-instance-test/SKILL.md +185 -0
- package/templates/skills/new-process/SKILL.md +119 -0
|
@@ -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.
|