@boxxy/store-sqlite 0.1.0 → 0.1.1
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/README.md +22 -3
- package/dist/apply-sqlite-change.d.ts +4 -0
- package/dist/build-sqlite-reader.d.ts +4 -0
- package/dist/create-sqlite-claim-store.d.ts +5 -0
- package/dist/create-sqlite-tx.d.ts +4 -0
- package/dist/create-tables-migration.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/parse-store-row.d.ts +2 -0
- package/dist/read-item-views.d.ts +5 -0
- package/dist/run-store-migrations.d.ts +3 -0
- package/dist/table-codecs.d.ts +10 -0
- package/dist/test-utils/create-test-claim-store.d.ts +2 -0
- package/dist/to-column-name.d.ts +1 -0
- package/dist/to-store-columns.d.ts +3 -0
- package/dist/types.d.ts +122 -0
- package/package.json +13 -1
- package/src/apply-sqlite-change.ts +220 -0
- package/src/build-sqlite-reader.ts +321 -0
- package/src/create-sqlite-claim-store.ts +92 -0
- package/src/create-sqlite-tx.ts +134 -0
- package/src/create-tables-migration.ts +146 -0
- package/src/index.ts +1 -1
- package/src/parse-store-row.ts +36 -0
- package/src/read-item-views.ts +92 -0
- package/src/run-store-migrations.ts +20 -0
- package/src/table-codecs.ts +111 -0
- package/src/test-utils/create-test-claim-store.ts +22 -0
- package/src/to-column-name.ts +3 -0
- package/src/to-store-columns.ts +44 -0
- package/src/types.ts +123 -0
- package/dist/package-name.d.ts +0 -1
- package/src/package-name.ts +0 -1
package/README.md
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
<a href="https://www.npmjs.com/package/@boxxy/store-sqlite"><img src="https://img.shields.io/npm/v/@boxxy/store-sqlite" alt="npm version"></a>
|
|
6
6
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
7
|
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/design/boxxy/README.md">Documentation</a>
|
|
11
|
+
</p>
|
|
8
12
|
</div>
|
|
9
13
|
|
|
10
|
-
boxxy's store on SQLite: the schema, the migrations and the transactions
|
|
14
|
+
boxxy's store on SQLite: the schema, the migrations and the transactions behind the `ClaimStore`
|
|
15
|
+
port.
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
`createSQLiteClaimStore(path)` opens one SQLite file, creates it when it's missing, runs the
|
|
18
|
+
migrations the file hasn't run, and returns the store with a `close` function.
|
|
14
19
|
|
|
15
20
|
## Install
|
|
16
21
|
|
|
@@ -21,3 +26,17 @@ bun add @boxxy/store-sqlite
|
|
|
21
26
|
```
|
|
22
27
|
|
|
23
28
|
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Items, levers, attempts, leases, pool slots and the outbox in one file, with each change applied
|
|
33
|
+
whole or not at all ([one write path](../../docs/design/boxxy/slice-1.md#one-write-path)).
|
|
34
|
+
- `exclusive` calls that run one at a time in a `BEGIN IMMEDIATE` transaction, and `read` calls that
|
|
35
|
+
see the last commit ([store rules](../../docs/design/boxxy/slice-1.md#store-rules)).
|
|
36
|
+
- Item state derived on each read, from the rows and the loaded rules
|
|
37
|
+
([derived item state](../../docs/design/boxxy/slice-1.md#derived-item-state)).
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
[The store schema](../../docs/design/boxxy/slice-1.md#the-store-schema) lists each table, and
|
|
42
|
+
[`02-ports.ts`](../../docs/design/boxxy/sketches/02-ports.ts) gives the shape of the port.
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { createSQLiteClaimStore, type SQLiteClaimStore } from './create-sqlite-claim-store.ts';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Instant, ItemWithLevers, MaxAttemptsByKind } from '@boxxy/model';
|
|
2
|
+
import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
|
|
3
|
+
import type { StoreDatabase } from './types.ts';
|
|
4
|
+
export type ItemScope = (builder: ExpressionBuilder<StoreDatabase, 'items'>) => Expression<SqlBool>;
|
|
5
|
+
export declare function readItemViews(db: Kysely<StoreDatabase>, scope: ItemScope, now: Instant, maxAttemptsByKind: MaxAttemptsByKind): Promise<ItemWithLevers[]>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TableName, Tables } from '@boxxy/model';
|
|
2
|
+
interface TableCodec<Row> {
|
|
3
|
+
readonly fields: readonly (keyof Row & string)[];
|
|
4
|
+
readonly json: readonly (keyof Row & string)[];
|
|
5
|
+
readonly flags: readonly (keyof Row & string)[];
|
|
6
|
+
}
|
|
7
|
+
export declare const tableCodecs: {
|
|
8
|
+
readonly [T in TableName]: TableCodec<Tables[T]>;
|
|
9
|
+
};
|
|
10
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toColumnName(field: string): string;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Generated } from 'kysely';
|
|
2
|
+
export interface StoreDatabase {
|
|
3
|
+
items: {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
title: string;
|
|
8
|
+
outcome: string;
|
|
9
|
+
acceptance: string;
|
|
10
|
+
found_in_attempt: string | null;
|
|
11
|
+
filed_by: string;
|
|
12
|
+
filed_at: number;
|
|
13
|
+
closed_at: number | null;
|
|
14
|
+
close_reason: string | null;
|
|
15
|
+
};
|
|
16
|
+
links: {
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
type: string;
|
|
20
|
+
added_by: string;
|
|
21
|
+
added_at: number;
|
|
22
|
+
removed_at: number | null;
|
|
23
|
+
};
|
|
24
|
+
levers: {
|
|
25
|
+
seq: number;
|
|
26
|
+
kind: string;
|
|
27
|
+
item: string;
|
|
28
|
+
until: number | null;
|
|
29
|
+
expires_at: number | null;
|
|
30
|
+
reason: string | null;
|
|
31
|
+
actor: string;
|
|
32
|
+
at: number;
|
|
33
|
+
};
|
|
34
|
+
attempts: {
|
|
35
|
+
id: string;
|
|
36
|
+
item: string;
|
|
37
|
+
phase: string;
|
|
38
|
+
number: number;
|
|
39
|
+
runner: string;
|
|
40
|
+
recipe_hash: string;
|
|
41
|
+
rules_version: string;
|
|
42
|
+
pool: string;
|
|
43
|
+
slot: number;
|
|
44
|
+
slot_gen: number;
|
|
45
|
+
lease_gen: number;
|
|
46
|
+
spawn_request: string;
|
|
47
|
+
spawn_request_hash: string;
|
|
48
|
+
state: string;
|
|
49
|
+
failure: string | null;
|
|
50
|
+
atc_session: string | null;
|
|
51
|
+
close_out: string | null;
|
|
52
|
+
claimed_at: number;
|
|
53
|
+
started_at: number | null;
|
|
54
|
+
ended_at: number | null;
|
|
55
|
+
};
|
|
56
|
+
attempt_sessions: {
|
|
57
|
+
attempt: string;
|
|
58
|
+
agent_session: string;
|
|
59
|
+
atc_session: string;
|
|
60
|
+
seen_at: number;
|
|
61
|
+
};
|
|
62
|
+
leases: {
|
|
63
|
+
item: string;
|
|
64
|
+
phase: string;
|
|
65
|
+
attempt: string;
|
|
66
|
+
gen: number;
|
|
67
|
+
taken_at: number;
|
|
68
|
+
released_at: number | null;
|
|
69
|
+
};
|
|
70
|
+
pool_slots: {
|
|
71
|
+
pool: string;
|
|
72
|
+
slot: number;
|
|
73
|
+
holder: string | null;
|
|
74
|
+
gen: number;
|
|
75
|
+
retired: number;
|
|
76
|
+
};
|
|
77
|
+
decisions: {
|
|
78
|
+
id: string;
|
|
79
|
+
attempt: string;
|
|
80
|
+
item: string;
|
|
81
|
+
pool: string;
|
|
82
|
+
rules_version: string;
|
|
83
|
+
snapshot_hash: string;
|
|
84
|
+
store_version: number;
|
|
85
|
+
pepys_offsets: string;
|
|
86
|
+
faults: string;
|
|
87
|
+
at: number;
|
|
88
|
+
};
|
|
89
|
+
evidence: {
|
|
90
|
+
fact_id: string;
|
|
91
|
+
attempt: string;
|
|
92
|
+
kind: string;
|
|
93
|
+
ref: string;
|
|
94
|
+
observed: string | null;
|
|
95
|
+
source: string;
|
|
96
|
+
at: number;
|
|
97
|
+
};
|
|
98
|
+
pull_requests: {
|
|
99
|
+
repo: string;
|
|
100
|
+
number: number;
|
|
101
|
+
attempt: string;
|
|
102
|
+
state: string;
|
|
103
|
+
merged_at: number | null;
|
|
104
|
+
merge_commit: string | null;
|
|
105
|
+
checked_at: number;
|
|
106
|
+
};
|
|
107
|
+
runners: {
|
|
108
|
+
id: string;
|
|
109
|
+
heartbeat_at: number;
|
|
110
|
+
paused: number;
|
|
111
|
+
};
|
|
112
|
+
outbox: {
|
|
113
|
+
seq: Generated<number>;
|
|
114
|
+
fact_id: string;
|
|
115
|
+
type: string;
|
|
116
|
+
entity: string;
|
|
117
|
+
actor: string;
|
|
118
|
+
at: number;
|
|
119
|
+
payload: string;
|
|
120
|
+
sent_at: number | null;
|
|
121
|
+
};
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/store-sqlite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "boxxy's store on SQLite",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/adapters/store-sqlite#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
"build": "rm -rf dist && tsrs -p tsconfig.build.json",
|
|
30
30
|
"typecheck": "tsrs --noEmit"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@boxxy/content-hash": "0.1.0",
|
|
34
|
+
"@boxxy/model": "0.1.2",
|
|
35
|
+
"@zgeoff/kysely-bun-sqlite": "0.3.2",
|
|
36
|
+
"kysely": "0.29.7",
|
|
37
|
+
"tiny-invariant": "1.3.3",
|
|
38
|
+
"ts-pattern": "5.9.2",
|
|
39
|
+
"zod": "4.6.7"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@boxxy/dispatch": "0.1.1"
|
|
43
|
+
},
|
|
32
44
|
"engines": {
|
|
33
45
|
"bun": ">=1.3",
|
|
34
46
|
"node": ">=24"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { buildAppliedChange, findSlotTake, toJSONValue } from '@boxxy/model';
|
|
2
|
+
import type { ApplyResult, RowWrite, SlotTake, StoreChange, TakenSlot } from '@boxxy/model';
|
|
3
|
+
import { sql } from 'kysely';
|
|
4
|
+
import type { Kysely, RawBuilder } from 'kysely';
|
|
5
|
+
import invariant from 'tiny-invariant';
|
|
6
|
+
import { match } from 'ts-pattern';
|
|
7
|
+
import { toStoreColumns } from './to-store-columns.ts';
|
|
8
|
+
import type { StoreValue } from './to-store-columns.ts';
|
|
9
|
+
import type { StoreDatabase } from './types.ts';
|
|
10
|
+
|
|
11
|
+
// A savepoint, not the transaction, so a refusal keeps the caller's earlier changes in the same
|
|
12
|
+
// exclusive call. The caller runs one change at a time, so one savepoint name is enough.
|
|
13
|
+
export async function applySQLiteChange(
|
|
14
|
+
db: Kysely<StoreDatabase>,
|
|
15
|
+
change: StoreChange,
|
|
16
|
+
): Promise<ApplyResult> {
|
|
17
|
+
invariant(change.facts.length > 0, 'a change writes at least one fact');
|
|
18
|
+
|
|
19
|
+
await sql`SAVEPOINT apply_change`.execute(db);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = await writeSQLiteChange(db, change);
|
|
23
|
+
|
|
24
|
+
if (result.kind !== 'applied') {
|
|
25
|
+
await sql`ROLLBACK TO apply_change`.execute(db);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await sql`RELEASE apply_change`.execute(db);
|
|
29
|
+
|
|
30
|
+
return result;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
await removeSavepoint(db, error);
|
|
33
|
+
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeSQLiteChange(
|
|
39
|
+
db: Kysely<StoreDatabase>,
|
|
40
|
+
change: StoreChange,
|
|
41
|
+
): Promise<ApplyResult> {
|
|
42
|
+
if (!(await probeLiveUnder(db, change.under))) {
|
|
43
|
+
return { kind: 'stale-generation' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const take = findSlotTake(change.writes);
|
|
47
|
+
const taken = take === null ? null : await writeSlotTake(db, take);
|
|
48
|
+
|
|
49
|
+
if (take !== null && taken === null) {
|
|
50
|
+
return { kind: 'no-free-slot' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lastLever = await db
|
|
54
|
+
.selectFrom('levers')
|
|
55
|
+
.select((builder) => builder.fn.max('seq').as('seq'))
|
|
56
|
+
.executeTakeFirst();
|
|
57
|
+
|
|
58
|
+
const applied = buildAppliedChange(change, { taken, lastLeverSeq: lastLever?.seq ?? 0 });
|
|
59
|
+
|
|
60
|
+
for (const write of applied.writes) {
|
|
61
|
+
const refusal = await writeSQLiteRow(db, write);
|
|
62
|
+
|
|
63
|
+
if (refusal !== null) {
|
|
64
|
+
return refusal;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const fact of applied.facts) {
|
|
69
|
+
await db
|
|
70
|
+
.insertInto('outbox')
|
|
71
|
+
.values({
|
|
72
|
+
fact_id: fact.id,
|
|
73
|
+
type: fact.type,
|
|
74
|
+
entity: fact.entity,
|
|
75
|
+
actor: fact.actor,
|
|
76
|
+
at: change.at,
|
|
77
|
+
payload: JSON.stringify(toJSONValue(fact.payload)),
|
|
78
|
+
sent_at: null,
|
|
79
|
+
})
|
|
80
|
+
.execute();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const slot = taken === null ? null : { pool: taken.pool, slot: taken.slot, gen: taken.gen };
|
|
84
|
+
|
|
85
|
+
return { kind: 'applied', slot };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function probeLiveUnder(
|
|
89
|
+
db: Kysely<StoreDatabase>,
|
|
90
|
+
under: StoreChange['under'],
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
if (under === undefined) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lease = await db
|
|
97
|
+
.selectFrom('leases')
|
|
98
|
+
.select('gen')
|
|
99
|
+
.where('item', '=', under.item)
|
|
100
|
+
.where('phase', '=', under.phase)
|
|
101
|
+
.where('gen', '=', under.gen)
|
|
102
|
+
.where('released_at', 'is', null)
|
|
103
|
+
.executeTakeFirst();
|
|
104
|
+
|
|
105
|
+
if (lease === undefined || under.slot === undefined) {
|
|
106
|
+
return lease !== undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const slot = await db
|
|
110
|
+
.selectFrom('pool_slots')
|
|
111
|
+
.select('gen')
|
|
112
|
+
.where('pool', '=', under.slot.pool)
|
|
113
|
+
.where('slot', '=', under.slot.slot)
|
|
114
|
+
.where('gen', '=', under.slot.gen)
|
|
115
|
+
.where('holder', 'is not', null)
|
|
116
|
+
.executeTakeFirst();
|
|
117
|
+
|
|
118
|
+
return slot !== undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeSlotTake(db: Kysely<StoreDatabase>, take: SlotTake): Promise<TakenSlot | null> {
|
|
122
|
+
const lowest = db
|
|
123
|
+
.selectFrom('pool_slots')
|
|
124
|
+
.select((builder) => builder.fn.min('slot').as('slot'))
|
|
125
|
+
.where('pool', '=', take.pool)
|
|
126
|
+
.where('holder', 'is', null)
|
|
127
|
+
.where('retired', '=', 0);
|
|
128
|
+
|
|
129
|
+
const row = await db
|
|
130
|
+
.updateTable('pool_slots')
|
|
131
|
+
.set((builder) => ({ holder: take.holder, gen: builder('gen', '+', 1) }))
|
|
132
|
+
.where('pool', '=', take.pool)
|
|
133
|
+
.where('slot', '=', lowest)
|
|
134
|
+
.where('holder', 'is', null)
|
|
135
|
+
.where('retired', '=', 0)
|
|
136
|
+
.returning(['slot', 'gen'])
|
|
137
|
+
.executeTakeFirst();
|
|
138
|
+
|
|
139
|
+
return row === undefined
|
|
140
|
+
? null
|
|
141
|
+
: { holder: take.holder, pool: take.pool, slot: row.slot, gen: row.gen };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<ApplyResult | null> {
|
|
145
|
+
return match(write)
|
|
146
|
+
.with({ op: 'take-slot' }, () => Promise.resolve(null))
|
|
147
|
+
.with({ op: 'release-slot' }, async (release) => {
|
|
148
|
+
const result = await db
|
|
149
|
+
.updateTable('pool_slots')
|
|
150
|
+
.set({ holder: null })
|
|
151
|
+
.where('pool', '=', release.pool)
|
|
152
|
+
.where('slot', '=', release.slot)
|
|
153
|
+
.where('holder', '=', release.holder)
|
|
154
|
+
.executeTakeFirst();
|
|
155
|
+
|
|
156
|
+
return result.numUpdatedRows === 0n ? ({ kind: 'stale-generation' } as const) : null;
|
|
157
|
+
})
|
|
158
|
+
.with({ op: 'insert', table: 'leases' }, async (insert) => {
|
|
159
|
+
const columns = toStoreColumns(insert.table, insert.row);
|
|
160
|
+
|
|
161
|
+
// ON CONFLICT names the partial unique index, so SQLite skips a second live lease, not throws
|
|
162
|
+
const result = await sql`${buildInsert(insert.table, columns)}
|
|
163
|
+
ON CONFLICT (item, phase) WHERE released_at IS NULL DO NOTHING`.execute(db);
|
|
164
|
+
|
|
165
|
+
return result.numAffectedRows === 0n ? ({ kind: 'lease-held' } as const) : null;
|
|
166
|
+
})
|
|
167
|
+
.with({ op: 'insert' }, async (insert) => {
|
|
168
|
+
await buildInsert(insert.table, toStoreColumns(insert.table, insert.row)).execute(db);
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
})
|
|
172
|
+
.with({ op: 'update' }, async (update) => {
|
|
173
|
+
const set = toStoreColumns(update.table, update.set);
|
|
174
|
+
|
|
175
|
+
const guards = [
|
|
176
|
+
...toStoreColumns(update.table, update.key),
|
|
177
|
+
...toStoreColumns(update.table, update.expect),
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
invariant(set.length > 0, 'an update sets at least one field');
|
|
181
|
+
|
|
182
|
+
const result = await sql`UPDATE ${sql.table(update.table)}
|
|
183
|
+
SET ${sql.join(set.map(([column, value]) => sql`${sql.ref(column)} = ${value}`))}
|
|
184
|
+
WHERE ${buildGuards(guards)}`.execute(db);
|
|
185
|
+
|
|
186
|
+
return result.numAffectedRows === 0n
|
|
187
|
+
? ({ kind: 'guard-mismatch', table: update.table, key: toJSONValue(update.key) } as const)
|
|
188
|
+
: null;
|
|
189
|
+
})
|
|
190
|
+
.exhaustive();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildInsert(table: string, columns: [string, StoreValue][]): RawBuilder<unknown> {
|
|
194
|
+
return sql`INSERT INTO ${sql.table(table)}
|
|
195
|
+
(${sql.join(columns.map(([column]) => sql.ref(column)))})
|
|
196
|
+
VALUES (${sql.join(columns.map(([, value]) => value))})`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// IS compares NULL as a value, so an expect of null matches a NULL column
|
|
200
|
+
function buildGuards(guards: [string, StoreValue][]): RawBuilder<unknown> {
|
|
201
|
+
invariant(guards.length > 0, 'an update names its key');
|
|
202
|
+
|
|
203
|
+
return sql.join(
|
|
204
|
+
guards.map(([column, value]) => sql`${sql.ref(column)} IS ${value}`),
|
|
205
|
+
sql` AND `,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// SQLite rolls the whole transaction back by itself after some errors, such as a full disk, and the
|
|
210
|
+
// savepoint is gone; the rollback's own error then joins the first one, never replaces it.
|
|
211
|
+
async function removeSavepoint(db: Kysely<StoreDatabase>, cause: unknown): Promise<void> {
|
|
212
|
+
try {
|
|
213
|
+
await sql`ROLLBACK TO apply_change`.execute(db);
|
|
214
|
+
await sql`RELEASE apply_change`.execute(db);
|
|
215
|
+
} catch (rollbackError) {
|
|
216
|
+
throw new AggregateError([cause, rollbackError], 'the change failed, and so did its rollback', {
|
|
217
|
+
cause: rollbackError,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|