@reventlessdev/reventless-local 3.0.0-alpha.219 → 3.0.0-alpha.221
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 +15 -0
- package/package.json +11 -11
- package/src/LocalPlatformRegistry.res +189 -0
- package/src/LocalPlatformRegistry.res.mjs +162 -0
- package/src/LocalSeedTarget.res +177 -0
- package/src/LocalSeedTarget.res.mjs +172 -0
- package/src/Platform.res +13 -0
- package/src/Platform.res.mjs +11 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +75 -7
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +38 -1
- package/src/adapter/Auth/LocalAuth.res +11 -26
- package/src/adapter/Auth/LocalAuth.res.mjs +5 -8
- package/src/adapter/BackendState.res +15 -0
- package/src/adapter/BackendState.res.mjs +29 -0
- package/src/adapter/PlatformGraphQL_Server.res +9 -4
- package/src/reset/LocalSeedReset.res +38 -16
- package/src/reset/LocalSeedReset.res.mjs +49 -21
- package/tests/LocalPlatformRegistryTest.res +143 -0
- package/tests/LocalPlatformRegistryTest.res.mjs +152 -0
- package/tests/adapter/LocalAuthLoginTest.res +44 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +32 -0
- package/tests/adapter/RequireGroupRefusalTest.res +107 -0
- package/tests/adapter/RequireGroupRefusalTest.res.mjs +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.221 (2026-08-14)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **local:** let the seed tools address a platform, not a guessed file ([98862ad](https://github.com/ReventlessDev/reventless-core/commit/98862adaa4111f605553e4a78bc52a8a488a4ef3))
|
|
11
|
+
* **local:** tell an unentitled caller apart from an unidentified one ([69c4fab](https://github.com/ReventlessDev/reventless-core/commit/69c4fabc522cff60b00cebc64c5428f5084ce82b))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.220 (2026-08-13)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.219 (2026-08-13)
|
|
7
22
|
|
|
8
23
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.221",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -35,18 +35,18 @@
|
|
|
35
35
|
"sury": "11.0.0-alpha.4",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
39
|
-
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
40
38
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
41
|
-
"@reventlessdev/rescript-
|
|
39
|
+
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
40
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
41
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.6",
|
|
42
42
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
43
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.233",
|
|
44
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.81",
|
|
45
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.180",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.97",
|
|
47
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.13",
|
|
48
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.140",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.113"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.0",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Where the local platforms running on this machine say who they are.
|
|
2
|
+
//
|
|
3
|
+
// A platform's store is otherwise invisible from outside its own process, so
|
|
4
|
+
// every tool that wants to act on "the" store — `seed`, `seed:reset`, a `sqlite3`
|
|
5
|
+
// poke — has to guess from its own environment. The guess is right exactly while
|
|
6
|
+
// there is one platform and wrong exactly when there are two, which is the case
|
|
7
|
+
// the tools exist for: a hand-started `pnpm run serve` on `local.db` beside the
|
|
8
|
+
// VS Code runner's child on `runner.db`, same app, same directory. Both then
|
|
9
|
+
// report success against different databases — a reset that empties an unserved
|
|
10
|
+
// store, and a seed that refuses because the served one is full.
|
|
11
|
+
//
|
|
12
|
+
// So a platform publishes itself instead: one JSON entry per run, next to the
|
|
13
|
+
// store it opened, removed on the way out. `LocalSeedTarget` turns that into the
|
|
14
|
+
// answer to "which platform do you mean".
|
|
15
|
+
//
|
|
16
|
+
// ── Why one file per port ──────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// The pair that broke is two platforms of the SAME app in the SAME package
|
|
19
|
+
// directory, which is precisely what a single `platform.json` cannot represent.
|
|
20
|
+
// The domain port is what already distinguishes them (`REVENTLESS_DOMAIN_PORT`,
|
|
21
|
+
// added so the runner could start one child per app without colliding), so it
|
|
22
|
+
// names the file.
|
|
23
|
+
//
|
|
24
|
+
// ── Why the path is absolute ───────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// `./.reventless/local.db` names a different file in every app. Relative to the
|
|
27
|
+
// writer's cwd it is unambiguous and outside it means nothing, so the entry
|
|
28
|
+
// carries the path `BackendState` resolved, not the env string it came from.
|
|
29
|
+
|
|
30
|
+
@schema
|
|
31
|
+
type store = {
|
|
32
|
+
// "sqlite" | "memory" | "postgres" — what a reader can DO with this platform's
|
|
33
|
+
// store, not which module implements it. A `:memory:` SQLite file reports
|
|
34
|
+
// "memory": it has nothing on disk to open, which is the only distinction a
|
|
35
|
+
// tool acting from outside can act on.
|
|
36
|
+
kind: string,
|
|
37
|
+
// Some only for a store this machine can open by name.
|
|
38
|
+
path: option<string>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@schema
|
|
42
|
+
type entry = {
|
|
43
|
+
app: string,
|
|
44
|
+
port: int,
|
|
45
|
+
pid: int,
|
|
46
|
+
endpoint: string,
|
|
47
|
+
loginEndpoint: string,
|
|
48
|
+
store: store,
|
|
49
|
+
startedAt: string,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `~cwd` is the directory the platform was started from, which is where its
|
|
53
|
+
// `.reventless/` already lives. Threaded rather than read at each use so the
|
|
54
|
+
// tests can exercise a registry without moving the whole process.
|
|
55
|
+
let runningDir = (~cwd=NodeProcess.cwd(), ()): string =>
|
|
56
|
+
NodePath.join([cwd, ".reventless", "running"])
|
|
57
|
+
|
|
58
|
+
let entryPath = (~port: int, ~cwd=NodeProcess.cwd()): string =>
|
|
59
|
+
NodePath.join([runningDir(~cwd, ()), `${port->Int.toString}.json`])
|
|
60
|
+
|
|
61
|
+
// The package this platform was started from, for the prompt line. Two platforms
|
|
62
|
+
// of one app are told apart by port; the name is there for the case where they
|
|
63
|
+
// are different apps and the port alone reads as an arbitrary number.
|
|
64
|
+
let appName = (~cwd=NodeProcess.cwd(), ()): string => {
|
|
65
|
+
let manifest = NodePath.join([cwd, "package.json"])
|
|
66
|
+
let fromManifest = if NodeFs.existsSync(manifest) {
|
|
67
|
+
try {
|
|
68
|
+
manifest
|
|
69
|
+
->NodeFs.readFileSync
|
|
70
|
+
->JSON.parseOrThrow
|
|
71
|
+
->JSON.Decode.object
|
|
72
|
+
->Option.flatMap(d => d->Dict.get("name"))
|
|
73
|
+
->Option.flatMap(JSON.Decode.string)
|
|
74
|
+
} catch {
|
|
75
|
+
| _ => None
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
None
|
|
79
|
+
}
|
|
80
|
+
fromManifest->Option.getOr(NodePath.basename(cwd))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let removeQuietly = (path: string): unit =>
|
|
84
|
+
try NodeFs.unlinkSync(path) catch {
|
|
85
|
+
| _ => ()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether a process is still there. Signal `0` performs no signalling — it only
|
|
89
|
+
runs the permission and existence checks and throws when either fails.
|
|
90
|
+
|
|
91
|
+
Liveness by pid rather than by probing the endpoint keeps this synchronous,
|
|
92
|
+
and prunes a wedged platform that holds its port but would never answer. */
|
|
93
|
+
let alive = (pid: int): bool =>
|
|
94
|
+
try {
|
|
95
|
+
NodeProcess.kill(pid, 0)
|
|
96
|
+
true
|
|
97
|
+
} catch {
|
|
98
|
+
| _ => false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let readEntry = (path: string): option<entry> =>
|
|
102
|
+
try Some(path->NodeFs.readFileSync->JSON.parseOrThrow->S.parseJsonOrThrow(entrySchema)) catch {
|
|
103
|
+
| _ => None
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every platform currently running in this directory, oldest port first.
|
|
107
|
+
|
|
108
|
+
Removal on exit is best-effort — a `kill -9` leaves the entry behind — so
|
|
109
|
+
staleness is settled here, on read: an entry whose process is gone is deleted
|
|
110
|
+
rather than reported. A reader that trusted the file would offer a platform
|
|
111
|
+
nothing is serving, which is the failure this module exists to remove. */
|
|
112
|
+
let list = (~cwd=NodeProcess.cwd(), ()): array<entry> => {
|
|
113
|
+
let dir = runningDir(~cwd, ())
|
|
114
|
+
if !NodeFs.existsSync(dir) {
|
|
115
|
+
[]
|
|
116
|
+
} else {
|
|
117
|
+
let files = try NodeFs.readdirSync(dir, {withFileTypes: true}) catch {
|
|
118
|
+
| _ => []
|
|
119
|
+
}
|
|
120
|
+
files
|
|
121
|
+
->Array.filter(d => d->NodeFs.isFile && d->NodeFs.direntName->String.endsWith(".json"))
|
|
122
|
+
->Array.filterMap(d => {
|
|
123
|
+
let path = NodePath.join([dir, d->NodeFs.direntName])
|
|
124
|
+
switch readEntry(path) {
|
|
125
|
+
| Some(entry) if alive(entry.pid) => Some(entry)
|
|
126
|
+
| _ =>
|
|
127
|
+
removeQuietly(path)
|
|
128
|
+
None
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
->Array.toSorted((a, b) => Int.compare(a.port, b.port))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Writes one entry and hands back the path it wrote, without touching the
|
|
136
|
+
process. Split from {!register} so a test can populate a registry without
|
|
137
|
+
installing exit handlers in the test runner. */
|
|
138
|
+
let write = (
|
|
139
|
+
~port: int,
|
|
140
|
+
~endpoint: string,
|
|
141
|
+
~loginEndpoint: string,
|
|
142
|
+
~store: store,
|
|
143
|
+
~pid: int=NodeProcess.pid,
|
|
144
|
+
~cwd: string=NodeProcess.cwd(),
|
|
145
|
+
): string => {
|
|
146
|
+
let path = entryPath(~port, ~cwd)
|
|
147
|
+
let entry = {
|
|
148
|
+
app: appName(~cwd, ()),
|
|
149
|
+
port,
|
|
150
|
+
pid,
|
|
151
|
+
endpoint,
|
|
152
|
+
loginEndpoint,
|
|
153
|
+
store,
|
|
154
|
+
startedAt: Date.make()->Date.toISOString,
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
NodeFs.mkdirSync(runningDir(~cwd, ()), {recursive: true})
|
|
158
|
+
NodeFs.writeFileSync(
|
|
159
|
+
path,
|
|
160
|
+
entry->S.reverseConvertToJsonOrThrow(entrySchema)->JSON.stringify(~space=2),
|
|
161
|
+
)
|
|
162
|
+
} catch {
|
|
163
|
+
// A platform that cannot announce itself must still serve. The tools fall back
|
|
164
|
+
// to their endpoint default, which is what they did before this existed.
|
|
165
|
+
| _ => ()
|
|
166
|
+
}
|
|
167
|
+
path
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Publishes this process, and arranges for it to stop being published.
|
|
171
|
+
|
|
172
|
+
Both shutdown paths are covered because they are not the same event: `exit`
|
|
173
|
+
fires with the process already committed to leaving and may only do
|
|
174
|
+
synchronous work, while `SIGINT`/`SIGTERM` fire before that decision — and
|
|
175
|
+
registering a signal handler at all is what suppresses Node's default exit,
|
|
176
|
+
so each one has to leave explicitly (128 + signal number, as a shell reports
|
|
177
|
+
it). */
|
|
178
|
+
let register = (~port: int, ~endpoint: string, ~loginEndpoint: string, ~store: store): unit => {
|
|
179
|
+
let path = write(~port, ~endpoint, ~loginEndpoint, ~store)
|
|
180
|
+
NodeProcess.onExit(_ => removeQuietly(path))
|
|
181
|
+
NodeProcess.onSignal(#SIGINT, () => {
|
|
182
|
+
removeQuietly(path)
|
|
183
|
+
NodeProcess.exit(130)
|
|
184
|
+
})
|
|
185
|
+
NodeProcess.onSignal(#SIGTERM, () => {
|
|
186
|
+
removeQuietly(path)
|
|
187
|
+
NodeProcess.exit(143)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Nodefs from "node:fs";
|
|
5
|
+
import * as Nodepath from "node:path";
|
|
6
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
7
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
8
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
9
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
10
|
+
|
|
11
|
+
let storeSchema = S.schema(s => ({
|
|
12
|
+
kind: s.m(S.string),
|
|
13
|
+
path: s.m(S.option(S.string))
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
let entrySchema = S.schema(s => ({
|
|
17
|
+
app: s.m(S.string),
|
|
18
|
+
port: s.m(S.int),
|
|
19
|
+
pid: s.m(S.int),
|
|
20
|
+
endpoint: s.m(S.string),
|
|
21
|
+
loginEndpoint: s.m(S.string),
|
|
22
|
+
store: s.m(storeSchema),
|
|
23
|
+
startedAt: s.m(S.string)
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function runningDir(cwdOpt, param) {
|
|
27
|
+
let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
|
|
28
|
+
return Nodepath.join(cwd, ".reventless", "running");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function entryPath(port, cwdOpt) {
|
|
32
|
+
let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
|
|
33
|
+
return Nodepath.join(runningDir(cwd, undefined), port.toString() + `.json`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appName(cwdOpt, param) {
|
|
37
|
+
let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
|
|
38
|
+
let manifest = Nodepath.join(cwd, "package.json");
|
|
39
|
+
let fromManifest;
|
|
40
|
+
if (Nodefs.existsSync(manifest)) {
|
|
41
|
+
try {
|
|
42
|
+
fromManifest = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(manifest, "utf8"))), d => d["name"]), Stdlib_JSON.Decode.string);
|
|
43
|
+
} catch (exn) {
|
|
44
|
+
fromManifest = undefined;
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
fromManifest = undefined;
|
|
48
|
+
}
|
|
49
|
+
return Stdlib_Option.getOr(fromManifest, Nodepath.basename(cwd));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeQuietly(path) {
|
|
53
|
+
try {
|
|
54
|
+
Nodefs.unlinkSync(path);
|
|
55
|
+
return;
|
|
56
|
+
} catch (exn) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function alive(pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (exn) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readEntry(path) {
|
|
71
|
+
try {
|
|
72
|
+
return S.parseJsonOrThrow(JSON.parse(Nodefs.readFileSync(path, "utf8")), entrySchema);
|
|
73
|
+
} catch (exn) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function list(cwdOpt, param) {
|
|
79
|
+
let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
|
|
80
|
+
let dir = runningDir(cwd, undefined);
|
|
81
|
+
if (!Nodefs.existsSync(dir)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
let files;
|
|
85
|
+
try {
|
|
86
|
+
files = Nodefs.readdirSync(dir, {
|
|
87
|
+
withFileTypes: true
|
|
88
|
+
});
|
|
89
|
+
} catch (exn) {
|
|
90
|
+
files = [];
|
|
91
|
+
}
|
|
92
|
+
return Stdlib_Array.filterMap(files.filter(d => {
|
|
93
|
+
if (d.isFile()) {
|
|
94
|
+
return d.name.endsWith(".json");
|
|
95
|
+
} else {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}), d => {
|
|
99
|
+
let path = Nodepath.join(dir, d.name);
|
|
100
|
+
let entry = readEntry(path);
|
|
101
|
+
if (entry !== undefined && alive(entry.pid)) {
|
|
102
|
+
return entry;
|
|
103
|
+
} else {
|
|
104
|
+
removeQuietly(path);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}).toSorted((a, b) => Primitive_int.compare(a.port, b.port));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function write(port, endpoint, loginEndpoint, store, pidOpt, cwdOpt) {
|
|
111
|
+
let pid = pidOpt !== undefined ? pidOpt : process.pid;
|
|
112
|
+
let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
|
|
113
|
+
let path = entryPath(port, cwd);
|
|
114
|
+
let entry_app = appName(cwd, undefined);
|
|
115
|
+
let entry_startedAt = new Date().toISOString();
|
|
116
|
+
let entry = {
|
|
117
|
+
app: entry_app,
|
|
118
|
+
port: port,
|
|
119
|
+
pid: pid,
|
|
120
|
+
endpoint: endpoint,
|
|
121
|
+
loginEndpoint: loginEndpoint,
|
|
122
|
+
store: store,
|
|
123
|
+
startedAt: entry_startedAt
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
Nodefs.mkdirSync(runningDir(cwd, undefined), {
|
|
127
|
+
recursive: true
|
|
128
|
+
});
|
|
129
|
+
Nodefs.writeFileSync(path, JSON.stringify(S.reverseConvertToJsonOrThrow(entry, entrySchema), undefined, 2), "utf8");
|
|
130
|
+
} catch (exn) {
|
|
131
|
+
|
|
132
|
+
}
|
|
133
|
+
return path;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function register(port, endpoint, loginEndpoint, store) {
|
|
137
|
+
let path = write(port, endpoint, loginEndpoint, store, undefined, undefined);
|
|
138
|
+
process.on("exit", param => removeQuietly(path));
|
|
139
|
+
process.on("SIGINT", () => {
|
|
140
|
+
removeQuietly(path);
|
|
141
|
+
process.exit(130);
|
|
142
|
+
});
|
|
143
|
+
process.on("SIGTERM", () => {
|
|
144
|
+
removeQuietly(path);
|
|
145
|
+
process.exit(143);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
storeSchema,
|
|
151
|
+
entrySchema,
|
|
152
|
+
runningDir,
|
|
153
|
+
entryPath,
|
|
154
|
+
appName,
|
|
155
|
+
removeQuietly,
|
|
156
|
+
alive,
|
|
157
|
+
readEntry,
|
|
158
|
+
list,
|
|
159
|
+
write,
|
|
160
|
+
register,
|
|
161
|
+
}
|
|
162
|
+
/* storeSchema Not a pure module */
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Which running platform `seed` and `seed:reset` are aimed at.
|
|
2
|
+
//
|
|
3
|
+
// The two are supposed to be inverses, and they were resolving their target by
|
|
4
|
+
// different means: `seed` addressed an ENDPOINT (`REVENTLESS_GRAPHQL_ENDPOINT`,
|
|
5
|
+
// defaulting to :4000), `seed:reset` opened a FILE (`REVENTLESS_LOCAL_BACKEND`,
|
|
6
|
+
// defaulting to ./.reventless/local.db). With two platforms up — a hand-started
|
|
7
|
+
// one and the VS Code runner's child — they answered about different databases
|
|
8
|
+
// and both reported success: "Nothing to do." followed by "the target store is
|
|
9
|
+
// not empty".
|
|
10
|
+
//
|
|
11
|
+
// One selector for both fixes that by construction. The endpoint identifies the
|
|
12
|
+
// platform (parallel platforms already differ by port); the platform names its
|
|
13
|
+
// store (`LocalPlatformRegistry`). Nothing is inferred from the tool's own
|
|
14
|
+
// environment except the deliberate override.
|
|
15
|
+
|
|
16
|
+
open ReventlessSeed
|
|
17
|
+
|
|
18
|
+
type origin =
|
|
19
|
+
| // A platform that is up and said so.
|
|
20
|
+
Running(LocalPlatformRegistry.entry)
|
|
21
|
+
| // `REVENTLESS_GRAPHQL_ENDPOINT` was set: the operator named a target, so no
|
|
22
|
+
// discovery runs and no prompt appears.
|
|
23
|
+
EnvOverride
|
|
24
|
+
| // Nothing is registered here. The endpoint default is still tried, so a
|
|
25
|
+
// platform from a build without the registry keeps working.
|
|
26
|
+
NoneRunning
|
|
27
|
+
|
|
28
|
+
type t = {endpoint: string, loginEndpoint: string, origin: origin}
|
|
29
|
+
|
|
30
|
+
let defaultEndpoint = "http://localhost:4000/graphql"
|
|
31
|
+
|
|
32
|
+
// The login route sits on the same server as the GraphQL one, so deriving it
|
|
33
|
+
// from the endpoint is what keeps a `REVENTLESS_GRAPHQL_ENDPOINT` pointing at
|
|
34
|
+
// :4010 from logging in against :4000 — a mismatch that used to require setting
|
|
35
|
+
// both variables to avoid, and silently seeded through the wrong door when only
|
|
36
|
+
// one was set. An explicit `REVENTLESS_LOGIN_ENDPOINT` still wins.
|
|
37
|
+
let loginFor = (endpoint: string): string =>
|
|
38
|
+
switch endpoint->String.indexOf("://") {
|
|
39
|
+
| -1 => endpoint
|
|
40
|
+
| i =>
|
|
41
|
+
let afterScheme = i + 3
|
|
42
|
+
switch endpoint->String.indexOfFrom("/", afterScheme) {
|
|
43
|
+
| -1 => endpoint
|
|
44
|
+
| slash => endpoint->String.slice(~start=0, ~end=slash)
|
|
45
|
+
} ++ "/__inmemory/login"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let relativeIfInside = (path: string): string => {
|
|
49
|
+
let cwd = NodeProcess.cwd()
|
|
50
|
+
path->String.startsWith(cwd ++ NodePath.sep)
|
|
51
|
+
? path->String.slice(~start=(cwd ++ NodePath.sep)->String.length)
|
|
52
|
+
: path
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let storeLabel = (store: LocalPlatformRegistry.store): string =>
|
|
56
|
+
switch (store.kind, store.path) {
|
|
57
|
+
| ("sqlite", Some(path)) => `sqlite ${relativeIfInside(path)}`
|
|
58
|
+
| (kind, _) => kind
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The scope is dropped from the app name for the menu only — `@scope/app-local`
|
|
62
|
+
// is the same width in every row and carries none of the distinction.
|
|
63
|
+
let shortAppName = (app: string): string =>
|
|
64
|
+
switch app->String.indexOf("/") {
|
|
65
|
+
| -1 => app
|
|
66
|
+
| i => app->String.slice(~start=i + 1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let entryLabel = (e: LocalPlatformRegistry.entry): string =>
|
|
70
|
+
`:${e.port->Int.toString} ${e.app->shortAppName} ${e.store->storeLabel}`
|
|
71
|
+
|
|
72
|
+
/** Resolves the platform this run acts on.
|
|
73
|
+
|
|
74
|
+
Precedence — explicit target, then the running platforms, then the historical
|
|
75
|
+
default:
|
|
76
|
+
|
|
77
|
+
1. `REVENTLESS_GRAPHQL_ENDPOINT` — named a target, so it is used verbatim.
|
|
78
|
+
2. one platform registered — it, with no prompt.
|
|
79
|
+
3. several — a menu, preselectable with `SEED_PLATFORM` (port or menu index)
|
|
80
|
+
so a run without a TTY is not stuck.
|
|
81
|
+
4. none — the :4000 default, so this stays backwards compatible with a
|
|
82
|
+
platform started from a build that does not register itself. */
|
|
83
|
+
let select = async (): t => {
|
|
84
|
+
switch Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT") {
|
|
85
|
+
| Some(endpoint) => {
|
|
86
|
+
endpoint,
|
|
87
|
+
loginEndpoint: Seed.Prompt.envValue("REVENTLESS_LOGIN_ENDPOINT")->Option.getOr(
|
|
88
|
+
endpoint->loginFor,
|
|
89
|
+
),
|
|
90
|
+
origin: EnvOverride,
|
|
91
|
+
}
|
|
92
|
+
| None =>
|
|
93
|
+
let running = LocalPlatformRegistry.list()
|
|
94
|
+
// Matched on the port rather than through `select`'s own `~env`, which
|
|
95
|
+
// compares against the whole rendered label — `SEED_PLATFORM=4000` is the
|
|
96
|
+
// form an operator would write, and no label will ever equal it.
|
|
97
|
+
let preselected =
|
|
98
|
+
Seed.Prompt.envValue("SEED_PLATFORM")->Option.flatMap(v =>
|
|
99
|
+
running->Array.find(e => e.port->Int.toString == v)
|
|
100
|
+
)
|
|
101
|
+
let chosen = switch (preselected, running) {
|
|
102
|
+
| (Some(entry), _) => Some(entry)
|
|
103
|
+
| (None, []) => None
|
|
104
|
+
| (None, entries) =>
|
|
105
|
+
Some(
|
|
106
|
+
await Seed.Prompt.select(
|
|
107
|
+
~title="Platform:",
|
|
108
|
+
~options=entries->Array.map(e => (e->entryLabel, e)),
|
|
109
|
+
~env="SEED_PLATFORM",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
switch chosen {
|
|
114
|
+
| Some(entry) => {
|
|
115
|
+
endpoint: entry.endpoint,
|
|
116
|
+
loginEndpoint: entry.loginEndpoint,
|
|
117
|
+
origin: Running(entry),
|
|
118
|
+
}
|
|
119
|
+
| None => {
|
|
120
|
+
endpoint: defaultEndpoint,
|
|
121
|
+
loginEndpoint: defaultEndpoint->loginFor,
|
|
122
|
+
origin: NoneRunning,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Says which platform was picked, before anything is written or deleted.
|
|
129
|
+
|
|
130
|
+
Printed even when there was no choice to make: the single-platform case is
|
|
131
|
+
the one that produced a reset and a seed acting on different stores, and one
|
|
132
|
+
line naming the endpoint and the store is what makes that visible. */
|
|
133
|
+
let announce = (t: t): unit =>
|
|
134
|
+
switch t.origin {
|
|
135
|
+
| Running(entry) =>
|
|
136
|
+
Console.log(`→ ${entry.endpoint} · ${entry.store->storeLabel} (${entry.app->shortAppName})`)
|
|
137
|
+
| EnvOverride => Console.log(`→ ${t.endpoint} (REVENTLESS_GRAPHQL_ENDPOINT)`)
|
|
138
|
+
| NoneRunning =>
|
|
139
|
+
Console.log(`→ ${t.endpoint} (no local platform registered here — trying the default)`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The store the selected platform opened: `Some(path)` only for one this
|
|
143
|
+
machine can open by name. `None` carries the reason, already phrased for the
|
|
144
|
+
operator, because "no path" has three different meanings and a tool that
|
|
145
|
+
collapsed them would tell an in-memory platform to check its disk. */
|
|
146
|
+
let storePath = (t: t): result<string, string> =>
|
|
147
|
+
switch t.origin {
|
|
148
|
+
| Running({store: {kind: "sqlite", path: Some(path)}}) => Ok(path)
|
|
149
|
+
| Running({store: {kind: "memory"}, endpoint}) =>
|
|
150
|
+
Error(`the platform at ${endpoint} keeps its store in memory; restart it to empty it.`)
|
|
151
|
+
| Running({store: {kind: "postgres"}, endpoint}) =>
|
|
152
|
+
Error(
|
|
153
|
+
`the platform at ${endpoint} is backed by Postgres, which keeps its event logs off this machine. Reset it against the database.`,
|
|
154
|
+
)
|
|
155
|
+
| Running({store: {kind}, endpoint}) =>
|
|
156
|
+
Error(`the platform at ${endpoint} reports an unknown store kind "${kind}".`)
|
|
157
|
+
| EnvOverride =>
|
|
158
|
+
Error(
|
|
159
|
+
`REVENTLESS_GRAPHQL_ENDPOINT names ${t.endpoint}, which does not say which store that platform opened. Set REVENTLESS_LOCAL_BACKEND to the store file, or unset REVENTLESS_GRAPHQL_ENDPOINT to pick a running platform.`,
|
|
160
|
+
)
|
|
161
|
+
| NoneRunning =>
|
|
162
|
+
Error(
|
|
163
|
+
"no local platform is running here. Start one, or set REVENTLESS_LOCAL_BACKEND to reset a store directly.",
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A `Seed.Runner.seed`-shaped `~connect` that targets the selected platform.
|
|
168
|
+
|
|
169
|
+
`~connect` is already a thunk, so the selection — prompt included — happens
|
|
170
|
+
when seeding starts rather than at module load, and `Seed_Connect.local`
|
|
171
|
+
stays untouched: it takes the endpoints, it does not go looking for them. */
|
|
172
|
+
let connect = (): (unit => promise<Seed.Connect.connection>) =>
|
|
173
|
+
async () => {
|
|
174
|
+
let target = await select()
|
|
175
|
+
target->announce
|
|
176
|
+
await Seed.Connect.local(~graphql=target.endpoint, ~login=target.loginEndpoint, ())()
|
|
177
|
+
}
|