@reventlessdev/reventless-local 3.0.0-alpha.220 → 3.0.0-alpha.222

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 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.222 (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
11
+
12
+
13
+ # 3.0.0-alpha.221 (2026-08-14)
14
+
15
+ ### Features
16
+
17
+ * **local:** let the seed tools address a platform, not a guessed file ([98862ad](https://github.com/ReventlessDev/reventless-core/commit/98862adaa4111f605553e4a78bc52a8a488a4ef3))
18
+ * **local:** tell an unentitled caller apart from an unidentified one ([69c4fab](https://github.com/ReventlessDev/reventless-core/commit/69c4fabc522cff60b00cebc64c5428f5084ce82b))
19
+
20
+
6
21
  # 3.0.0-alpha.220 (2026-08-13)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.220",
3
+ "version": "3.0.0-alpha.222",
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-graphql-yoga": "1.0.0-alpha.27",
39
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
38
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.5",
42
- "@reventlessdev/reventless-core": "3.0.0-alpha.232",
39
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
+ "@reventlessdev/rescript-node": "2.0.0-alpha.6",
41
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
43
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.80",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.179",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
48
- "@reventlessdev/reventless-seed": "1.0.0-alpha.12",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.234",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.82",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.181",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.141",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.98",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.13",
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
+ }