@rehearsal-db/core 0.1.0-beta.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/BENCHMARKS.md +61 -0
- package/CHANGELOG.md +59 -0
- package/COMPATIBILITY.md +22 -0
- package/LICENSE +21 -0
- package/README.md +375 -0
- package/SECURITY.md +19 -0
- package/SUPPORT.md +15 -0
- package/docs/adapters.md +23 -0
- package/docs/baselines.md +33 -0
- package/docs/commands.md +35 -0
- package/docs/configuration.md +98 -0
- package/docs/getting-started.md +247 -0
- package/docs/glossary.md +33 -0
- package/docs/production-source.md +33 -0
- package/docs/releasing.md +79 -0
- package/docs/sanitization.md +69 -0
- package/docs/security-model.md +40 -0
- package/docs/troubleshooting.md +50 -0
- package/docs/tutorial.md +96 -0
- package/package.json +77 -0
- package/scripts/lib/environment/local_supabase.mjs +197 -0
- package/scripts/lib/rehearsal/baseline_artifact.mjs +536 -0
- package/scripts/lib/rehearsal/baseline_builder.mjs +155 -0
- package/scripts/lib/rehearsal/configuration.d.mts +85 -0
- package/scripts/lib/rehearsal/configuration.mjs +559 -0
- package/scripts/lib/rehearsal/diagnostics.mjs +193 -0
- package/scripts/lib/rehearsal/migration_history.mjs +220 -0
- package/scripts/lib/rehearsal/plan.mjs +587 -0
- package/scripts/lib/rehearsal/process_environment.mjs +64 -0
- package/scripts/lib/rehearsal/runtime_restore.mjs +310 -0
- package/scripts/lib/rehearsal/sanitization_policy.mjs +169 -0
- package/scripts/lib/rehearsal/schema_snapshot.mjs +113 -0
- package/scripts/lib/rehearsal/service_environment.mjs +82 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +784 -0
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +565 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Provide stable Rehearsal result, failure, redaction, and rendering
|
|
3
|
+
* contracts for humans and automation. Do not run directly; this module is reusable
|
|
4
|
+
* script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export const REHEARSAL_RESULT_VERSION = 1;
|
|
10
|
+
export const REHEARSAL_VERSION = "0.1.0-beta.1";
|
|
11
|
+
|
|
12
|
+
export const REHEARSAL_EXIT_CODES = Object.freeze({
|
|
13
|
+
success: 0,
|
|
14
|
+
configuration_invalid: 2,
|
|
15
|
+
unsafe_environment: 3,
|
|
16
|
+
baseline_invalid: 4,
|
|
17
|
+
baseline_checksum_mismatch: 5,
|
|
18
|
+
migration_candidate_failure: 6,
|
|
19
|
+
migration_verification_failure: 7,
|
|
20
|
+
application_proof_failure: 8,
|
|
21
|
+
runtime_dependency_failure: 9,
|
|
22
|
+
internal_failure: 10,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const SECRET_KEY_PATTERN =
|
|
26
|
+
/(?:authorization|cookie|credential|database[_-]?url|jwt|key|password|secret|service[_-]?role|token)/iu;
|
|
27
|
+
const JWT_PATTERN =
|
|
28
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
|
29
|
+
const CONNECTION_STRING_PATTERN =
|
|
30
|
+
/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s"']+/giu;
|
|
31
|
+
const BEARER_PATTERN = /\bBearer\s+[^\s"']+/giu;
|
|
32
|
+
const SUPABASE_KEY_PATTERN = /\bsb_(?:publishable|secret)_[A-Za-z0-9_-]+\b/gu;
|
|
33
|
+
|
|
34
|
+
const redactString = (value) =>
|
|
35
|
+
value
|
|
36
|
+
.replace(CONNECTION_STRING_PATTERN, "[REDACTED_CONNECTION_STRING]")
|
|
37
|
+
.replace(JWT_PATTERN, "[REDACTED_JWT]")
|
|
38
|
+
.replace(BEARER_PATTERN, "Bearer [REDACTED]")
|
|
39
|
+
.replace(SUPABASE_KEY_PATTERN, "[REDACTED_KEY]");
|
|
40
|
+
|
|
41
|
+
export const redactDiagnosticValue = (value, key = "") => {
|
|
42
|
+
if (SECRET_KEY_PATTERN.test(key)) return "[REDACTED]";
|
|
43
|
+
if (typeof value === "string") return redactString(value);
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
return value.map((entry) => redactDiagnosticValue(entry));
|
|
46
|
+
}
|
|
47
|
+
if (value && typeof value === "object") {
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
Object.entries(value).map(([entryKey, entryValue]) => [
|
|
50
|
+
entryKey,
|
|
51
|
+
redactDiagnosticValue(entryValue, entryKey),
|
|
52
|
+
]),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const diagnosticIdFor = ({ category, code, message }) =>
|
|
59
|
+
createHash("sha256")
|
|
60
|
+
.update(`${category}\0${code}\0${message}`)
|
|
61
|
+
.digest("hex")
|
|
62
|
+
.slice(0, 12);
|
|
63
|
+
|
|
64
|
+
export class RehearsalError extends Error {
|
|
65
|
+
constructor({
|
|
66
|
+
category,
|
|
67
|
+
code,
|
|
68
|
+
message,
|
|
69
|
+
expected,
|
|
70
|
+
actual,
|
|
71
|
+
context,
|
|
72
|
+
refused,
|
|
73
|
+
suggestions = [],
|
|
74
|
+
cause,
|
|
75
|
+
}) {
|
|
76
|
+
super(message, { cause });
|
|
77
|
+
this.name = "RehearsalError";
|
|
78
|
+
if (!Object.hasOwn(REHEARSAL_EXIT_CODES, category)) {
|
|
79
|
+
throw new Error(`Unknown Rehearsal error category: ${category}.`);
|
|
80
|
+
}
|
|
81
|
+
this.category = category;
|
|
82
|
+
this.code = code;
|
|
83
|
+
this.expected = expected;
|
|
84
|
+
this.actual = actual;
|
|
85
|
+
this.context = context;
|
|
86
|
+
this.refused = refused;
|
|
87
|
+
this.suggestions = suggestions;
|
|
88
|
+
this.diagnosticId = diagnosticIdFor({ category, code, message });
|
|
89
|
+
this.exitCode = REHEARSAL_EXIT_CODES[category];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const inferCategory = (error) => {
|
|
94
|
+
const message = String(error?.message ?? error);
|
|
95
|
+
if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
|
|
96
|
+
if (/candidate|migration history diverges/iu.test(message)) {
|
|
97
|
+
return "migration_candidate_failure";
|
|
98
|
+
}
|
|
99
|
+
if (/baseline|artifact|generation/iu.test(message)) return "baseline_invalid";
|
|
100
|
+
if (/migration|ledger|replay/iu.test(message)) {
|
|
101
|
+
return "migration_verification_failure";
|
|
102
|
+
}
|
|
103
|
+
if (/config|unknown rehearsal|unsupported rehearsal/iu.test(message)) {
|
|
104
|
+
return "configuration_invalid";
|
|
105
|
+
}
|
|
106
|
+
if (/loopback|hosted|unsafe|credential|outbound/iu.test(message)) {
|
|
107
|
+
return "unsafe_environment";
|
|
108
|
+
}
|
|
109
|
+
if (/docker|supabase|runtime|command|enoent/iu.test(message)) {
|
|
110
|
+
return "runtime_dependency_failure";
|
|
111
|
+
}
|
|
112
|
+
return "internal_failure";
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const normalizeRehearsalError = (error, overrides = {}) => {
|
|
116
|
+
if (error instanceof RehearsalError) return error;
|
|
117
|
+
const message = String(error?.message ?? error);
|
|
118
|
+
const category = overrides.category ?? inferCategory(error);
|
|
119
|
+
return new RehearsalError({
|
|
120
|
+
category,
|
|
121
|
+
code: overrides.code ?? category.toUpperCase(),
|
|
122
|
+
message,
|
|
123
|
+
expected: overrides.expected,
|
|
124
|
+
actual: overrides.actual,
|
|
125
|
+
context: overrides.context,
|
|
126
|
+
refused:
|
|
127
|
+
overrides.refused ??
|
|
128
|
+
"Rehearsal stopped before performing the requested operation.",
|
|
129
|
+
suggestions: overrides.suggestions ?? [],
|
|
130
|
+
cause: error,
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const createRehearsalResult = ({
|
|
135
|
+
command,
|
|
136
|
+
status,
|
|
137
|
+
data,
|
|
138
|
+
warnings = [],
|
|
139
|
+
startedAt = new Date(),
|
|
140
|
+
durationMs = 0,
|
|
141
|
+
}) =>
|
|
142
|
+
redactDiagnosticValue({
|
|
143
|
+
schemaVersion: REHEARSAL_RESULT_VERSION,
|
|
144
|
+
rehearsalVersion: REHEARSAL_VERSION,
|
|
145
|
+
command,
|
|
146
|
+
status,
|
|
147
|
+
startedAt: startedAt.toISOString(),
|
|
148
|
+
durationMs,
|
|
149
|
+
warnings,
|
|
150
|
+
data,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const serializeRehearsalError = (error, { debug = false } = {}) => {
|
|
154
|
+
const failure = normalizeRehearsalError(error);
|
|
155
|
+
return redactDiagnosticValue({
|
|
156
|
+
schemaVersion: REHEARSAL_RESULT_VERSION,
|
|
157
|
+
rehearsalVersion: REHEARSAL_VERSION,
|
|
158
|
+
status: "error",
|
|
159
|
+
error: {
|
|
160
|
+
category: failure.category,
|
|
161
|
+
code: failure.code,
|
|
162
|
+
message: failure.message,
|
|
163
|
+
expected: failure.expected,
|
|
164
|
+
actual: failure.actual,
|
|
165
|
+
context: failure.context,
|
|
166
|
+
refused: failure.refused,
|
|
167
|
+
suggestions: failure.suggestions,
|
|
168
|
+
diagnosticId: failure.diagnosticId,
|
|
169
|
+
...(debug && failure.cause
|
|
170
|
+
? { cause: String(failure.cause?.message ?? failure.cause) }
|
|
171
|
+
: {}),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const formatValue = (value) =>
|
|
177
|
+
typeof value === "string" ? value : JSON.stringify(value);
|
|
178
|
+
|
|
179
|
+
export const renderHumanError = (error, { debug = false } = {}) => {
|
|
180
|
+
const { error: failure } = serializeRehearsalError(error, { debug });
|
|
181
|
+
return [
|
|
182
|
+
`Rehearsal stopped: ${failure.message}`,
|
|
183
|
+
failure.expected ? `Expected: ${formatValue(failure.expected)}` : null,
|
|
184
|
+
failure.actual ? `Actual: ${formatValue(failure.actual)}` : null,
|
|
185
|
+
failure.context ? `Context: ${formatValue(failure.context)}` : null,
|
|
186
|
+
failure.refused ? `Refused: ${failure.refused}` : null,
|
|
187
|
+
...(failure.suggestions ?? []).map((value) => `Try: ${value}`),
|
|
188
|
+
`Diagnostic: ${failure.diagnosticId} (${failure.category})`,
|
|
189
|
+
debug && failure.cause ? `Cause: ${failure.cause}` : null,
|
|
190
|
+
]
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.join("\n");
|
|
193
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build and compare content-addressed application migration histories for
|
|
3
|
+
* Rehearsal without trusting timestamp-only migration alignment. Do not run directly;
|
|
4
|
+
* this module is reusable script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
const migrationNamePattern = /^(\d{14})_(.+)\.sql$/u;
|
|
11
|
+
|
|
12
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
13
|
+
|
|
14
|
+
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
15
|
+
|
|
16
|
+
const normalizeStatement = (statement) => {
|
|
17
|
+
if (typeof statement !== "string") {
|
|
18
|
+
throw new Error("A Rehearsal migration ledger statement is not text.");
|
|
19
|
+
}
|
|
20
|
+
return statement.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const assertOrderedUniqueHistory = (entries, label) => {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
let previous = null;
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (!/^\d{14}$/u.test(entry.version)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`${label} has an invalid migration version: ${entry.version}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (seen.has(entry.version)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${label} contains duplicate migration ${entry.version}.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (previous !== null && entry.version <= previous) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`${label} is not in strictly increasing migration order at ${entry.version}.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
seen.add(entry.version);
|
|
43
|
+
previous = entry.version;
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const readMigrationFileInventory = async (directory) => {
|
|
49
|
+
const names = (await readdir(directory))
|
|
50
|
+
.filter((name) => name.endsWith(".sql"))
|
|
51
|
+
.sort();
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const filename of names) {
|
|
54
|
+
const match = migrationNamePattern.exec(filename);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Invalid migration filename in ${directory}: ${filename}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const bytes = await readFile(new URL(filename, directory));
|
|
61
|
+
entries.push({
|
|
62
|
+
version: match[1],
|
|
63
|
+
name: match[2],
|
|
64
|
+
filename,
|
|
65
|
+
fileSha256: sha256(bytes),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return assertOrderedUniqueHistory(entries, "Migration files");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const readMigrationSourceBundle = async ({
|
|
72
|
+
directory,
|
|
73
|
+
sourceLedger,
|
|
74
|
+
}) => {
|
|
75
|
+
assertOrderedUniqueHistory(sourceLedger, "Source migration ledger");
|
|
76
|
+
const inventory = await readMigrationFileInventory(directory);
|
|
77
|
+
if (sourceLedger.length > inventory.length) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"The source migration history contains a version absent from local migration source.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const bundle = [];
|
|
83
|
+
for (let index = 0; index < sourceLedger.length; index += 1) {
|
|
84
|
+
const source = sourceLedger[index];
|
|
85
|
+
const file = inventory[index];
|
|
86
|
+
if (source.version !== file.version || source.name !== file.name) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Source migration ${source.version}_${source.name} does not match local file ${file.filename}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
bundle.push({
|
|
92
|
+
...file,
|
|
93
|
+
content: await readFile(new URL(file.filename, directory), "utf8"),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return bundle;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const buildMigrationLedgerInventory = (
|
|
100
|
+
rows,
|
|
101
|
+
label = "Migration ledger",
|
|
102
|
+
) => {
|
|
103
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
104
|
+
throw new Error(`${label} is empty or unavailable.`);
|
|
105
|
+
}
|
|
106
|
+
const entries = rows.map((row) => {
|
|
107
|
+
if (!row || typeof row !== "object" || typeof row.name !== "string") {
|
|
108
|
+
throw new Error(`${label} contains an invalid row.`);
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(row.statements) || row.statements.length === 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${label} migration ${row.version ?? "unknown"} has no applied statements.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const statements = row.statements.map(normalizeStatement);
|
|
116
|
+
return {
|
|
117
|
+
version: String(row.version),
|
|
118
|
+
name: row.name,
|
|
119
|
+
statementCount: statements.length,
|
|
120
|
+
statementSha256: sha256(JSON.stringify(statements)),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
return assertOrderedUniqueHistory(entries, label);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const createMigrationReplayReceipt = ({ files, ledger }) => {
|
|
127
|
+
assertOrderedUniqueHistory(files, "Migration files");
|
|
128
|
+
assertOrderedUniqueHistory(ledger, "Replayed migration ledger");
|
|
129
|
+
if (files.length !== ledger.length) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Migration replay length mismatch: ${files.length} files but ${ledger.length} applied ledger rows.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const entries = files.map((file, index) => {
|
|
135
|
+
const applied = ledger[index];
|
|
136
|
+
if (file.version !== applied.version || file.name !== applied.name) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Migration replay mismatch at ${file.filename}: ledger contains ${applied.version}_${applied.name}.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
version: file.version,
|
|
143
|
+
name: file.name,
|
|
144
|
+
filename: file.filename,
|
|
145
|
+
fileSha256: file.fileSha256,
|
|
146
|
+
statementCount: applied.statementCount,
|
|
147
|
+
statementSha256: applied.statementSha256,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
entries,
|
|
152
|
+
historySha256: sha256(canonicalJson(entries)),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const compareSourceToReplay = ({
|
|
157
|
+
sourceLedger,
|
|
158
|
+
replayReceipt,
|
|
159
|
+
snapshotSchemaSha256,
|
|
160
|
+
}) => {
|
|
161
|
+
assertOrderedUniqueHistory(sourceLedger, "Source migration ledger");
|
|
162
|
+
if (!replayReceipt?.entries?.length || !replayReceipt.historySha256) {
|
|
163
|
+
throw new Error("The migration replay receipt is missing or invalid.");
|
|
164
|
+
}
|
|
165
|
+
if (sourceLedger.length > replayReceipt.entries.length) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"The source migration history contains a version absent from the reviewed replay.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const snapshotBacked = snapshotSchemaSha256 !== undefined;
|
|
171
|
+
if (snapshotBacked && !/^[a-f0-9]{64}$/u.test(snapshotSchemaSha256)) {
|
|
172
|
+
throw new Error("The production schema snapshot receipt is invalid.");
|
|
173
|
+
}
|
|
174
|
+
let historicalStatementDifferences = 0;
|
|
175
|
+
for (let index = 0; index < sourceLedger.length; index += 1) {
|
|
176
|
+
const source = sourceLedger[index];
|
|
177
|
+
const replayed = replayReceipt.entries[index];
|
|
178
|
+
if (source.version !== replayed.version || source.name !== replayed.name) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Source migration history is not an exact replay prefix at ${source.version}_${source.name}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
source.statementCount !== replayed.statementCount ||
|
|
185
|
+
source.statementSha256 !== replayed.statementSha256
|
|
186
|
+
) {
|
|
187
|
+
if (!snapshotBacked) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Applied statement content differs for migration ${source.version}_${source.name}.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
historicalStatementDifferences += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const candidates = replayReceipt.entries.slice(sourceLedger.length);
|
|
196
|
+
return {
|
|
197
|
+
cutoff: sourceLedger.at(-1).version,
|
|
198
|
+
candidates,
|
|
199
|
+
candidateSha256: sha256(canonicalJson(candidates)),
|
|
200
|
+
historicalStatementDifferences,
|
|
201
|
+
snapshotSchemaSha256: snapshotBacked ? snapshotSchemaSha256 : null,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const assertNoCrossDatabaseMigrationVersions = ({
|
|
206
|
+
applicationFiles,
|
|
207
|
+
apiFiles,
|
|
208
|
+
}) => {
|
|
209
|
+
const applicationVersions = new Set(
|
|
210
|
+
applicationFiles.map((entry) => entry.version),
|
|
211
|
+
);
|
|
212
|
+
const collisions = apiFiles
|
|
213
|
+
.map((entry) => entry.version)
|
|
214
|
+
.filter((version) => applicationVersions.has(version));
|
|
215
|
+
if (collisions.length) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Primary and secondary database migration versions collide: ${collisions.join(", ")}.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
};
|