@reventlessdev/reventless-local 3.0.0-alpha.199 → 3.0.0-alpha.201
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 +17 -0
- package/package.json +10 -9
- package/rescript.json +2 -1
- package/src/Platform.res +137 -86
- package/src/Platform.res.mjs +57 -44
- package/src/adapter/BackendState.res +13 -0
- package/src/adapter/BackendState.res.mjs +17 -1
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +1 -1
- package/src/adapter/DomainGraphQL_Server.res +5 -5
- package/src/adapter/DomainGraphQL_Server.res.mjs +1 -1
- package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +1 -1
- package/src/adapter/LocalBus.res +6 -46
- package/src/adapter/LocalBus.res.mjs +0 -29
- package/src/adapter/LocalStateChangeDescriptor.res +93 -0
- package/src/adapter/LocalStateChangeDescriptor.res.mjs +61 -0
- package/src/adapter/LocalUploadResolvers.res +16 -3
- package/src/adapter/LocalUploadResolvers.res.mjs +6 -4
- package/src/adapter/ObjectStore/LocalObjectStore.res +157 -0
- package/src/adapter/ObjectStore/LocalObjectStore.res.mjs +129 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +215 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs +286 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res +33 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs +47 -0
- package/src/adapter/QueryDb/LocalQueryDbStorage.res.mjs +1 -1
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +4 -2
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +3 -3
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +4 -2
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +3 -3
- package/src/reset/LocalSeedReset.res +552 -0
- package/src/reset/LocalSeedReset.res.mjs +533 -0
- package/tests/adapter/BackendParityTest.res +51 -0
- package/tests/adapter/BackendParityTest.res.mjs +44 -0
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res +6 -4
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +4 -3
- package/tests/adapter/ObjectStorePersistenceTest.res +243 -0
- package/tests/adapter/ObjectStorePersistenceTest.res.mjs +159 -0
- package/tests/reset/LocalSeedResetTest.res +252 -0
- package/tests/reset/LocalSeedResetTest.res.mjs +280 -0
- package/src/adapter/LocalObjectStore.res +0 -70
- package/src/adapter/LocalObjectStore.res.mjs +0 -60
|
@@ -193,19 +193,21 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
193
193
|
|
|
194
194
|
let publishSaved = (~changeKind: string, id: string, state: JSON.t) => {
|
|
195
195
|
let subKey = getSubKey(state, subIdField)
|
|
196
|
-
let descriptor =
|
|
196
|
+
let descriptor = LocalStateChangeDescriptor.make(
|
|
197
197
|
~changeKind,
|
|
198
198
|
~id=entityKeyFor(id, subKey),
|
|
199
199
|
~state=Some(state),
|
|
200
|
+
~seq=LocalStateChangeDescriptor.nextSequence(),
|
|
200
201
|
)
|
|
201
202
|
Bus.publishStateChange(~name, ~descriptor)
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
let publishRemoved = (id: string, subKey: string) => {
|
|
205
|
-
let descriptor =
|
|
206
|
+
let descriptor = LocalStateChangeDescriptor.make(
|
|
206
207
|
~changeKind="Removed",
|
|
207
208
|
~id=entityKeyFor(id, subKey),
|
|
208
209
|
~state=None,
|
|
210
|
+
~seq=LocalStateChangeDescriptor.nextSequence(),
|
|
209
211
|
)
|
|
210
212
|
Bus.publishStateChange(~name, ~descriptor)
|
|
211
213
|
}
|
|
@@ -6,7 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
import * as Stream from "effect/Stream";
|
|
7
7
|
import * as Pulumi from "@pulumi/pulumi";
|
|
8
8
|
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
9
|
-
import * as
|
|
9
|
+
import * as LocalStateChangeDescriptor$ReventlessLocal from "../LocalStateChangeDescriptor.res.mjs";
|
|
10
10
|
|
|
11
11
|
function getSubKey(item, subIdField) {
|
|
12
12
|
if (subIdField === undefined) {
|
|
@@ -167,11 +167,11 @@ function Make(Bus) {
|
|
|
167
167
|
};
|
|
168
168
|
let publishSaved = (changeKind, id, state) => {
|
|
169
169
|
let subKey = getSubKey(state, subIdField);
|
|
170
|
-
let descriptor =
|
|
170
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
171
171
|
Bus.publishStateChange(name, descriptor);
|
|
172
172
|
};
|
|
173
173
|
let publishRemoved = (id, subKey) => {
|
|
174
|
-
let descriptor =
|
|
174
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
175
175
|
Bus.publishStateChange(name, descriptor);
|
|
176
176
|
};
|
|
177
177
|
let save = async (id, state, _saveMode, ttl) => {
|
|
@@ -257,10 +257,11 @@ let makeStorage = (
|
|
|
257
257
|
|
|
258
258
|
let publishSaved = (~changeKind: string, id: string, state: JSON.t) => {
|
|
259
259
|
let subKey = computeSubKey(state, subIdField)
|
|
260
|
-
let descriptor =
|
|
260
|
+
let descriptor = LocalStateChangeDescriptor.make(
|
|
261
261
|
~changeKind,
|
|
262
262
|
~id=entityKeyFor(id, subKey),
|
|
263
263
|
~state=Some(state),
|
|
264
|
+
~seq=LocalStateChangeDescriptor.nextSequence(),
|
|
264
265
|
)
|
|
265
266
|
bus.publishStateChange(~name, ~descriptor)
|
|
266
267
|
}
|
|
@@ -278,10 +279,11 @@ let makeStorage = (
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
let publishRemoved = (id: string, subKey: string) => {
|
|
281
|
-
let descriptor =
|
|
282
|
+
let descriptor = LocalStateChangeDescriptor.make(
|
|
282
283
|
~changeKind="Removed",
|
|
283
284
|
~id=entityKeyFor(id, subKey),
|
|
284
285
|
~state=None,
|
|
286
|
+
~seq=LocalStateChangeDescriptor.nextSequence(),
|
|
285
287
|
)
|
|
286
288
|
bus.publishStateChange(~name, ~descriptor)
|
|
287
289
|
}
|
|
@@ -5,9 +5,9 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
5
5
|
import * as Stream from "effect/Stream";
|
|
6
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
7
7
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
8
|
-
import * as LocalBus$ReventlessLocal from "../LocalBus.res.mjs";
|
|
9
8
|
import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
|
|
10
9
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
10
|
+
import * as LocalStateChangeDescriptor$ReventlessLocal from "../LocalStateChangeDescriptor.res.mjs";
|
|
11
11
|
|
|
12
12
|
function tableName(name) {
|
|
13
13
|
return "qdb_" + name.replaceAll("-", "_");
|
|
@@ -194,7 +194,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
194
194
|
};
|
|
195
195
|
let publishSaved = (changeKind, id, state) => {
|
|
196
196
|
let subKey = computeSubKey(state, subIdField);
|
|
197
|
-
let descriptor =
|
|
197
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
198
198
|
bus.publishStateChange(name, descriptor);
|
|
199
199
|
};
|
|
200
200
|
let saveKind = (id, state) => {
|
|
@@ -209,7 +209,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
209
209
|
}
|
|
210
210
|
};
|
|
211
211
|
let publishRemoved = (id, subKey) => {
|
|
212
|
-
let descriptor =
|
|
212
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
213
213
|
bus.publishStateChange(name, descriptor);
|
|
214
214
|
};
|
|
215
215
|
let rowKeysForPartition = id => SqliteDriver$ReventlessLocal.all(selectByPartitionStmt, [id]).map(row => {
|
|
@@ -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
|
+
}
|