@reventlessdev/reventless-local 3.0.0-alpha.200 → 3.0.0-alpha.202

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +10 -9
  3. package/rescript.json +2 -1
  4. package/src/Platform.res +70 -37
  5. package/src/Platform.res.mjs +48 -34
  6. package/src/adapter/BackendState.res +13 -0
  7. package/src/adapter/BackendState.res.mjs +17 -1
  8. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +1 -1
  9. package/src/adapter/DomainGraphQL_Server.res +5 -5
  10. package/src/adapter/DomainGraphQL_Server.res.mjs +1 -1
  11. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +1 -1
  12. package/src/adapter/LocalBus.res +6 -46
  13. package/src/adapter/LocalBus.res.mjs +0 -29
  14. package/src/adapter/LocalStateChangeDescriptor.res +93 -0
  15. package/src/adapter/LocalStateChangeDescriptor.res.mjs +61 -0
  16. package/src/adapter/LocalUploadResolvers.res +16 -3
  17. package/src/adapter/LocalUploadResolvers.res.mjs +6 -4
  18. package/src/adapter/ObjectStore/LocalObjectStore.res +157 -0
  19. package/src/adapter/ObjectStore/LocalObjectStore.res.mjs +129 -0
  20. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +215 -0
  21. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs +286 -0
  22. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res +33 -0
  23. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs +47 -0
  24. package/src/adapter/QueryDb/LocalQueryDbStorage.res.mjs +1 -1
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +4 -2
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +3 -3
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +4 -2
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +3 -3
  29. package/src/reset/LocalSeedReset.res +552 -0
  30. package/src/reset/LocalSeedReset.res.mjs +533 -0
  31. package/tests/adapter/BackendParityTest.res +51 -0
  32. package/tests/adapter/BackendParityTest.res.mjs +44 -0
  33. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +6 -4
  34. package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +4 -3
  35. package/tests/adapter/ObjectStorePersistenceTest.res +243 -0
  36. package/tests/adapter/ObjectStorePersistenceTest.res.mjs +159 -0
  37. package/tests/components/eventlog/EventLogProvisioningSeamTest.res +110 -0
  38. package/tests/components/eventlog/EventLogProvisioningSeamTest.res.mjs +141 -0
  39. package/tests/reset/LocalSeedResetTest.res +252 -0
  40. package/tests/reset/LocalSeedResetTest.res.mjs +280 -0
  41. package/src/adapter/LocalObjectStore.res +0 -70
  42. package/src/adapter/LocalObjectStore.res.mjs +0 -60
@@ -0,0 +1,552 @@
1
+ // Scoped, in-place reset of a local platform's store — the local counterpart of
2
+ // `ReventlessSeedAws_Reset`, and the inverse of `pnpm run seed`:
3
+ // `Seed.Runner.assertStoreEmpty` refuses to seed a non-empty store, and this makes
4
+ // a non-empty store empty again.
5
+ //
6
+ // Two properties it takes from the deployed version and one it does not need:
7
+ //
8
+ // • It EMPTIES, it does not destroy. AWS never drops a table, it deletes rows.
9
+ // Deleting `.reventless/local.db` is not the local analogue — and while the
10
+ // platform runs it is actively wrong: the process holds the file open, so the
11
+ // unlink leaves it on the orphaned inode still serving every row it had, the
12
+ // delete looks like it worked, and the seed then fails against the untouched
13
+ // server with "the target store is not empty". Deleting ROWS through a second
14
+ // connection is visible to the running server immediately (its reads go to
15
+ // these tables; there is no in-process cache), so a reset needs no restart and
16
+ // no stopping of the platform.
17
+ //
18
+ // • It is SCOPED. Wiping domain data leaves the plugin registry intact, so a
19
+ // re-seed just works — the same reason the deployed default is `domain`.
20
+ //
21
+ // • It needs none of AWS's gates (name allowlist, `wipeable` flag, tag-scoped
22
+ // discovery, typed confirm). Those exist because that target is remote, shared
23
+ // and irreversible; this one is a file in a git-ignored directory that
24
+ // `serve:reset` already wipes without ceremony. A printed plan and one y/N is
25
+ // the proportionate equivalent.
26
+ //
27
+ // ── Why classification is discovery-first ──────────────────────────────────
28
+ //
29
+ // The obvious design — read each connected plugin's `pluginStructure` and wipe what
30
+ // it lists — under-deletes, measurably. In the hybrid example the store holds
31
+ // `qdb_ImportProductAudit` and three `*Todo` tables that appear in NO structure's
32
+ // arrays, and `Platform_Admin_Structure` does not mention `qdb_UiFragments` either.
33
+ // So neither "domain = what the plugins claim" nor "platform = the remainder" is
34
+ // sound.
35
+ //
36
+ // Instead: discover what is actually in the store, classify against a CLOSED
37
+ // platform allowlist, and let domain be everything else. That polarity is the point
38
+ // — a reset that misses domain rows fails the re-seed it exists to enable, while one
39
+ // that catches an unexpected domain table does what the operator asked. Per-plugin
40
+ // scope attributes positively from that plugin's structure and REPORTS what it
41
+ // cannot attribute, rather than guessing either way.
42
+
43
+ open ReventlessSeed
44
+
45
+ // ── Scope ───────────────────────────────────────────────────────────────────
46
+
47
+ type scope =
48
+ | Domain
49
+ | Platform
50
+ | Everything
51
+ | OnePlugin(string)
52
+
53
+ let scopeLabel = (s: scope) =>
54
+ switch s {
55
+ | Domain => "domain"
56
+ | Platform => "platform"
57
+ | Everything => "everything"
58
+ | OnePlugin(p) => p
59
+ }
60
+
61
+ // ── The platform's own components ───────────────────────────────────────────
62
+ //
63
+ // A closed set, taken from core's constants rather than spelled as literals so it
64
+ // moves when core does. `qdb_UiFragments` is here because the UI fragment registry
65
+ // is platform-owned even though the platform's structure omits it — the omission
66
+ // this list exists to survive. LocalSeedResetTest's "platform claims exactly the
67
+ // platform set" is the pin: it fails if core gains a platform-owned component and
68
+ // this list is not updated with it.
69
+
70
+ let platformQueryables = [ReventlessCore.PluginsReadModelSpec.name, ReventlessCore.UiFragments.name]
71
+
72
+ let platformWritables = [ReventlessCore.PluginSpec.name]
73
+
74
+ // `Categories` → `qdb_Categories`, matching QueryDbStorage_Sqlite.tableName.
75
+ let qdbTableName = (name: string): string => "qdb_" ++ name->String.replaceAll("-", "_")
76
+
77
+ // `Plugin` → `PluginAggrEventLog`, matching the Bus key an aggregate's event log
78
+ // takes (ComponentType.name applied twice).
79
+ let aggregateLogName = (name: string): string =>
80
+ ReventlessCore.ComponentType.name(ReventlessCore.ComponentType.name(name, Aggregate), EventLog)
81
+
82
+ // A checkpoint row belongs to the component whose events it tracks, but its key is
83
+ // not reconstructible from the component name: all of `CategoriesEventColl`,
84
+ // `CustomersReadModelEventColl`, `UiFragmentsEventColl` and
85
+ // `PluginsReadModelEventColl` occur, so the `ReadModel` infix is present for some
86
+ // components and absent for others. Strip instead of build: drop a leading `dcb:`,
87
+ // a trailing `EventColl`, then a trailing `ReadModel`, and match what remains.
88
+ let checkpointComponent = (readModel: string): string => {
89
+ let withoutDcb =
90
+ readModel->String.startsWith("dcb:")
91
+ ? readModel->String.slice(~start=String.length("dcb:"), ~end=readModel->String.length)
92
+ : readModel
93
+ let withoutColl =
94
+ withoutDcb->String.endsWith("EventColl")
95
+ ? withoutDcb->String.slice(
96
+ ~start=0,
97
+ ~end=withoutDcb->String.length - String.length("EventColl"),
98
+ )
99
+ : withoutDcb
100
+ withoutColl->String.endsWith("ReadModel")
101
+ ? withoutColl->String.slice(
102
+ ~start=0,
103
+ ~end=withoutColl->String.length - String.length("ReadModel"),
104
+ )
105
+ : withoutColl
106
+ }
107
+
108
+ // ── Discovery ───────────────────────────────────────────────────────────────
109
+
110
+ let tableExists = (db, name) =>
111
+ db
112
+ ->SqliteDriver.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
113
+ ->SqliteDriver.get([JSON.Encode.string(name)])
114
+ ->Option.isSome
115
+
116
+ let strings = (rows: array<dict<JSON.t>>, column: string): array<string> =>
117
+ rows->Array.filterMap(row =>
118
+ switch row->Dict.get(column) {
119
+ | Some(JSON.String(s)) => Some(s)
120
+ | _ => None
121
+ }
122
+ )
123
+
124
+ let qdbTables = (db): array<string> =>
125
+ db
126
+ ->SqliteDriver.prepare(
127
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'qdb\\_%' ESCAPE '\\' ORDER BY name",
128
+ )
129
+ ->SqliteDriver.all([])
130
+ ->strings("name")
131
+
132
+ let distinct = (db, ~table, ~column): array<string> =>
133
+ tableExists(db, table)
134
+ ? db
135
+ ->SqliteDriver.prepare(`SELECT DISTINCT ${column} AS v FROM ${table} ORDER BY v`)
136
+ ->SqliteDriver.all([])
137
+ ->strings("v")
138
+ : []
139
+
140
+ let countWhere = (db, ~table, ~column, ~value): int =>
141
+ if !tableExists(db, table) {
142
+ 0
143
+ } else {
144
+ switch db
145
+ ->SqliteDriver.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE ${column} = ?`)
146
+ ->SqliteDriver.get([JSON.Encode.string(value)]) {
147
+ | Some(row) =>
148
+ switch row->Dict.get("c") {
149
+ | Some(JSON.Number(n)) => n->Float.toInt
150
+ | _ => 0
151
+ }
152
+ | None => 0
153
+ }
154
+ }
155
+
156
+ let countAll = (db, ~table): int =>
157
+ if !tableExists(db, table) {
158
+ 0
159
+ } else {
160
+ switch db->SqliteDriver.prepare(`SELECT COUNT(*) AS c FROM ${table}`)->SqliteDriver.get([]) {
161
+ | Some(row) =>
162
+ switch row->Dict.get("c") {
163
+ | Some(JSON.Number(n)) => n->Float.toInt
164
+ | _ => 0
165
+ }
166
+ | None => 0
167
+ }
168
+ }
169
+
170
+ // ── Plugin structures ───────────────────────────────────────────────────────
171
+ //
172
+ // Read field-by-field rather than decoded through `pluginStructureSchema`: only the
173
+ // component names are needed, and a strict decode would make an unrelated schema
174
+ // addition break the reset for a store written before it. `Message.parseJsonTolerant`
175
+ // heals a stale event on the read path; a maintenance tool can simply not depend on
176
+ // the parts it does not read.
177
+
178
+ type pluginComponents = {
179
+ plugin: string,
180
+ queryables: array<string>,
181
+ writables: array<string>,
182
+ stores: array<string>,
183
+ }
184
+
185
+ let namesIn = (structure: dict<JSON.t>, field: string): array<string> =>
186
+ switch structure->Dict.get(field) {
187
+ | Some(JSON.Array(items)) =>
188
+ items->Array.filterMap(item =>
189
+ switch item {
190
+ | JSON.Object(o) =>
191
+ switch o->Dict.get("name") {
192
+ | Some(JSON.String(s)) => Some(s)
193
+ | _ => None
194
+ }
195
+ | _ => None
196
+ }
197
+ )
198
+ | _ => []
199
+ }
200
+
201
+ // `structure` is inline or an `{$offload: {key, …}}` reference into the object
202
+ // store — the same field the ComponentDefinitions Lambda resolves from S3 on the
203
+ // deployed side, resolved here from `offload/`.
204
+ let resolveStructure = (~root: string, json: JSON.t): option<dict<JSON.t>> =>
205
+ switch json {
206
+ | JSON.Object(o) =>
207
+ switch o->Dict.get(Reventless.Offload.sentinelKey) {
208
+ | Some(JSON.Object(ref)) =>
209
+ switch ref->Dict.get("key") {
210
+ | Some(JSON.String(key)) =>
211
+ ObjectStoreStorage_FileSystem.getOffload(~root, ~key)->Option.flatMap(bytes =>
212
+ switch try bytes->JSON.parseOrThrow catch {
213
+ | _ => JSON.Encode.null
214
+ } {
215
+ | JSON.Object(resolved) => Some(resolved)
216
+ | _ => None
217
+ }
218
+ )
219
+ | _ => None
220
+ }
221
+ | _ => Some(o)
222
+ }
223
+ | _ => None
224
+ }
225
+
226
+ let readPlugins = (db, ~root: string): array<pluginComponents> => {
227
+ let table = qdbTableName(ReventlessCore.PluginsReadModelSpec.name)
228
+ if !tableExists(db, table) {
229
+ []
230
+ } else {
231
+ db
232
+ ->SqliteDriver.prepare(`SELECT partition_key, item FROM ${table}`)
233
+ ->SqliteDriver.all([])
234
+ ->Array.filterMap(row =>
235
+ switch (row->Dict.get("partition_key"), row->Dict.get("item")) {
236
+ | (Some(JSON.String(plugin)), Some(JSON.String(item))) =>
237
+ switch try item->JSON.parseOrThrow catch {
238
+ | _ => JSON.Encode.null
239
+ } {
240
+ | JSON.Object(o) =>
241
+ o
242
+ ->Dict.get("structure")
243
+ ->Option.flatMap(s => resolveStructure(~root, s))
244
+ ->Option.map(structure => {
245
+ plugin,
246
+ queryables: Array.concat(
247
+ namesIn(structure, "readModels"),
248
+ namesIn(structure, "stateViewSlices"),
249
+ ),
250
+ writables: Array.concat(
251
+ namesIn(structure, "aggregates"),
252
+ namesIn(structure, "stateChangeSlices"),
253
+ ),
254
+ stores: switch structure->Dict.get("requiredStores") {
255
+ | Some(JSON.Array(items)) =>
256
+ items->Array.filterMap(
257
+ i =>
258
+ switch i {
259
+ | JSON.String(s) => Some(s)
260
+ | _ => None
261
+ },
262
+ )
263
+ | _ => []
264
+ },
265
+ })
266
+ | _ => None
267
+ }
268
+ | _ => None
269
+ }
270
+ )
271
+ }
272
+ }
273
+
274
+ // ── The plan ────────────────────────────────────────────────────────────────
275
+
276
+ type item = {label: string, count: int, run: unit => unit}
277
+
278
+ type plan = {
279
+ scope: scope,
280
+ items: array<item>,
281
+ /** Discovered components no plugin structure claims, under a per-plugin scope.
282
+ Reported, never silently swept in or left out. */
283
+ unattributed: array<string>,
284
+ }
285
+
286
+ let total = (p: plan) => p.items->Array.reduce(0, (sum, i) => sum + i.count)
287
+
288
+ let deleteWhere = (db, ~table, ~column, ~value) =>
289
+ if tableExists(db, table) {
290
+ db
291
+ ->SqliteDriver.prepare(`DELETE FROM ${table} WHERE ${column} = ?`)
292
+ ->SqliteDriver.run([JSON.Encode.string(value)])
293
+ }
294
+
295
+ let clearTable = (db, ~table) =>
296
+ if tableExists(db, table) {
297
+ // Contents, never DROP: the running platform holds prepared statements and
298
+ // indexes against these tables.
299
+ db->SqliteDriver.exec(`DELETE FROM ${table}`)
300
+ }
301
+
302
+ let build = (db, ~root: string, ~scope: scope): plan => {
303
+ let plugins = readPlugins(db, ~root)
304
+
305
+ let platformQdb = platformQueryables->Array.map(qdbTableName)
306
+ let platformLogs = platformWritables->Array.map(aggregateLogName)
307
+ let isPlatformQdb = t => platformQdb->Array.includes(t)
308
+ let isPlatformLog = l => platformLogs->Array.includes(l)
309
+
310
+ let selectedPlugins = switch scope {
311
+ | OnePlugin(name) => plugins->Array.filter(p => p.plugin == name)
312
+ | Domain | Everything => plugins
313
+ | Platform => []
314
+ }
315
+
316
+ // Which discovered tables/logs this scope claims.
317
+ let claimsQdb = (table: string) =>
318
+ switch scope {
319
+ | Platform => isPlatformQdb(table)
320
+ | Everything => true
321
+ | Domain => !isPlatformQdb(table)
322
+ | OnePlugin(_) =>
323
+ selectedPlugins->Array.some(p => p.queryables->Array.some(q => qdbTableName(q) == table))
324
+ }
325
+
326
+ let claimsLog = (log: string) =>
327
+ switch scope {
328
+ | Platform => isPlatformLog(log)
329
+ | Everything => true
330
+ | Domain => !isPlatformLog(log)
331
+ | OnePlugin(_) =>
332
+ selectedPlugins->Array.some(p =>
333
+ p.writables->Array.some(w => aggregateLogName(w) == log || w == log)
334
+ )
335
+ }
336
+
337
+ let claimedQdb = qdbTables(db)->Array.filter(claimsQdb)
338
+ let claimedComponents =
339
+ claimedQdb->Array.map(t => t->String.slice(~start=String.length("qdb_"), ~end=t->String.length))
340
+
341
+ let items = []
342
+
343
+ claimedQdb->Array.forEach(table =>
344
+ items->Array.push({
345
+ label: table,
346
+ count: countAll(db, ~table),
347
+ run: () => clearTable(db, ~table),
348
+ })
349
+ )
350
+
351
+ // Event logs and their snapshots move together: leaving a snapshot behind
352
+ // strands the aggregate on state whose events are gone.
353
+ ["event_log", "snapshot"]->Array.forEach(table =>
354
+ distinct(db, ~table, ~column="log_name")
355
+ ->Array.filter(claimsLog)
356
+ ->Array.forEach(log =>
357
+ items->Array.push({
358
+ label: `${table} (${log})`,
359
+ count: countWhere(db, ~table, ~column="log_name", ~value=log),
360
+ run: () => deleteWhere(db, ~table, ~column="log_name", ~value=log),
361
+ })
362
+ )
363
+ )
364
+
365
+ ["dcb_event", "dcb_tag"]->Array.forEach(table =>
366
+ distinct(db, ~table, ~column="log_name")
367
+ ->Array.filter(claimsLog)
368
+ ->Array.forEach(log =>
369
+ items->Array.push({
370
+ label: `${table} (${log})`,
371
+ count: countWhere(db, ~table, ~column="log_name", ~value=log),
372
+ run: () => deleteWhere(db, ~table, ~column="log_name", ~value=log),
373
+ })
374
+ )
375
+ )
376
+
377
+ // A checkpoint without its read model's rows would stop the re-seeded events
378
+ // ever being projected.
379
+ distinct(db, ~table="projection_checkpoint", ~column="read_model")
380
+ ->Array.filter(rm => claimedComponents->Array.includes(checkpointComponent(rm)))
381
+ ->Array.forEach(rm =>
382
+ items->Array.push({
383
+ label: `projection_checkpoint (${rm})`,
384
+ count: countWhere(db, ~table="projection_checkpoint", ~column="read_model", ~value=rm),
385
+ run: () => deleteWhere(db, ~table="projection_checkpoint", ~column="read_model", ~value=rm),
386
+ })
387
+ )
388
+
389
+ // Objects, by the prefix their declaring store roots them at — the local
390
+ // equivalent of the deployed reset's prefix-scoped bucket wipe.
391
+ let claimedPrefixes = switch scope {
392
+ | Platform => []
393
+ | Everything | Domain =>
394
+ // Every prefix present, declared or not: an object under `uploads/` was minted
395
+ // before its store declared a prefix (or by a plugin that declares none), and
396
+ // it is domain data either way.
397
+ ObjectStoreStorage_FileSystem.topLevelPrefixes(~root)
398
+ | OnePlugin(_) =>
399
+ selectedPlugins->Array.flatMap(p =>
400
+ p.stores->Array.map(qualified => LocalObjectStore.localPrefixFor(~qualified))
401
+ )
402
+ }
403
+ claimedPrefixes->Array.forEach(prefix => {
404
+ let count = ObjectStoreStorage_FileSystem.keysUnder(~root, ~prefix)->Array.length
405
+ if count > 0 {
406
+ items->Array.push({
407
+ label: `objects/${prefix}/`,
408
+ count,
409
+ run: () => ObjectStoreStorage_FileSystem.deleteUnder(~root, ~prefix)->ignore,
410
+ })
411
+ }
412
+ })
413
+
414
+ // Offloaded payloads are the platform's own store (plugin definitions), so they
415
+ // go with the registry that references them and never with a domain wipe.
416
+ switch scope {
417
+ | Platform | Everything =>
418
+ let count = ObjectStoreStorage_FileSystem.offloadKeys(~root)->Array.length
419
+ if count > 0 {
420
+ items->Array.push({
421
+ label: "offload/",
422
+ count,
423
+ run: () => ObjectStoreStorage_FileSystem.deleteOffloadAll(~root)->ignore,
424
+ })
425
+ }
426
+ | Domain | OnePlugin(_) => ()
427
+ }
428
+
429
+ // Under a per-plugin scope, say what NO plugin's structure claims — not merely
430
+ // what falls outside this scope. Another plugin's tables are attributed, just not
431
+ // selected, and listing them here would read as a gap in the tool rather than a
432
+ // narrower scope. What is left is the genuinely unclaimable: components no
433
+ // structure mentions, which only a `domain` scope reaches.
434
+ let unattributed = switch scope {
435
+ | OnePlugin(_) =>
436
+ let claimedByAnyPlugin =
437
+ plugins->Array.flatMap(p => p.queryables->Array.map(qdbTableName))
438
+ qdbTables(db)->Array.filter(t =>
439
+ !isPlatformQdb(t) && !(claimedByAnyPlugin->Array.includes(t))
440
+ )
441
+ | Domain | Platform | Everything => []
442
+ }
443
+
444
+ {scope, items, unattributed}
445
+ }
446
+
447
+ // ── Reporting and execution ─────────────────────────────────────────────────
448
+
449
+ let describe = (p: plan): unit => {
450
+ Console.log("")
451
+ Console.log(`Reset scope: ${p.scope->scopeLabel}`)
452
+ Console.log("")
453
+ if p.items->Array.length == 0 {
454
+ Console.log(" (nothing — this scope already reads empty)")
455
+ } else {
456
+ p.items->Array.forEach(i =>
457
+ Console.log(` ${i.count->Int.toString->String.padStart(7, " ")} ${i.label}`)
458
+ )
459
+ }
460
+ if p.unattributed->Array.length > 0 {
461
+ Console.log("")
462
+ Console.log(` Left alone — no connected plugin's structure claims them (widen the scope to include them):`)
463
+ p.unattributed->Array.forEach(t => Console.log(` ${t}`))
464
+ }
465
+ Console.log("")
466
+ }
467
+
468
+ let execute = (db, p: plan): unit =>
469
+ db->SqliteDriver.transaction(() => p.items->Array.forEach(i => i.run()))
470
+
471
+ // ── Entry point ─────────────────────────────────────────────────────────────
472
+
473
+ let scopeOptions = (plugins: array<string>): array<(string, scope)> =>
474
+ Array.concat(
475
+ Array.concat([("domain", Domain)], plugins->Array.map(p => (p, OnePlugin(p)))),
476
+ [("platform", Platform), ("everything", Everything)],
477
+ )
478
+
479
+ /** Runs the reset against the store the local platform would open.
480
+
481
+ `dbPath` defaults to what `REVENTLESS_LOCAL_BACKEND` selects, so the tool and
482
+ the platform cannot disagree about which store is "the" store. A Memory or
483
+ Postgres backend has no local file to reset and says so rather than appearing
484
+ to work. */
485
+ let run = (~dbPath: option<string>=?): unit => {
486
+ let go = async () => {
487
+ let resolved = switch dbPath {
488
+ | Some(p) => Some(p)
489
+ | None =>
490
+ switch Backend.fromEnv() {
491
+ | Backend.Sqlite({path}) if path != ":memory:" => Some(path)
492
+ | Backend.Sqlite(_) | Backend.Memory =>
493
+ Console.log(
494
+ "Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.",
495
+ )
496
+ None
497
+ | Backend.Postgres(_) =>
498
+ Console.log(
499
+ "Nothing to reset here — the Postgres backend keeps its event logs off this machine. Reset it against the database.",
500
+ )
501
+ None
502
+ }
503
+ }
504
+
505
+ switch resolved {
506
+ | None => ()
507
+ | Some(path) =>
508
+ if !NodeFs.existsSync(path) {
509
+ Console.log(`Nothing to reset — no store at ${path}.`)
510
+ } else {
511
+ let root = NodePath.dirname(path)
512
+ let db = SqliteDriver.openDb(~path)
513
+ let plugins = readPlugins(db, ~root)->Array.map(p => p.plugin)
514
+ let scope = await Seed.Prompt.select(
515
+ ~title="Reset scope:",
516
+ ~options=scopeOptions(plugins),
517
+ ~env="SEED_RESET_SCOPE",
518
+ )
519
+ let plan = build(db, ~root, ~scope)
520
+ describe(plan)
521
+ if total(plan) == 0 {
522
+ Console.log("Nothing to do.")
523
+ } else {
524
+ let confirmed = switch Seed.Prompt.envValue("SEED_RESET_CONFIRM") {
525
+ | Some("1") | Some("yes") => true
526
+ | _ =>
527
+ let answer = await Seed.Prompt.ask(
528
+ `Empty ${total(
529
+ plan,
530
+ )->Int.toString} row(s)/object(s) in the "${plan.scope->scopeLabel}" scope? [y/N]: `,
531
+ )
532
+ answer->String.trim->String.toLowerCase == "y"
533
+ }
534
+ if confirmed {
535
+ execute(db, plan)
536
+ Console.log(
537
+ `Reset complete — the "${plan.scope->scopeLabel}" scope reads empty and is re-seedable.`,
538
+ )
539
+ } else {
540
+ Console.log("Nothing was deleted.")
541
+ }
542
+ }
543
+ Seed.Prompt.close()
544
+ db->SqliteDriver.close
545
+ }
546
+ }
547
+ }
548
+ // Kicking a top-level async body off from a `unit` entry point, as the seed
549
+ // harness and the deployed reset both do — the one place the floating promise
550
+ // is the interface rather than an oversight.
551
+ go()->ignore
552
+ }