wowbagger 0.1.0-alpha.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/CHANGELOG.md +94 -0
- package/LICENSE +201 -0
- package/README.md +464 -0
- package/adapters/claude-code/entrypoint.js +19 -0
- package/adapters/claude-code/wowbagger-adapter.json +25 -0
- package/adapters/codex/entrypoint.js +11 -0
- package/adapters/codex/wowbagger-adapter.json +25 -0
- package/adapters/opencode/entrypoint.js +11 -0
- package/adapters/opencode/wowbagger-adapter.json +25 -0
- package/bin/wowbagger.js +7 -0
- package/package.json +51 -0
- package/skills/wowbagger/SKILL.md +136 -0
- package/src/adapter/approval.js +135 -0
- package/src/adapter/bootstrap.js +43 -0
- package/src/adapter/context.js +34 -0
- package/src/adapter/core-probe.js +231 -0
- package/src/adapter/describe.js +383 -0
- package/src/adapter/entrypoint-main.js +335 -0
- package/src/adapter/entrypoint-path.js +103 -0
- package/src/adapter/handoff.js +124 -0
- package/src/adapter/instructions.js +106 -0
- package/src/adapter/invoke.js +294 -0
- package/src/adapter/limits.js +26 -0
- package/src/adapter/manifest.js +93 -0
- package/src/adapter/messages.js +15 -0
- package/src/adapter/paths.js +88 -0
- package/src/adapter/process-outcome.js +1116 -0
- package/src/adapter/schema-helpers.js +60 -0
- package/src/claim-capabilities.js +54 -0
- package/src/claim-coordinator.js +85 -0
- package/src/claim-journal.js +236 -0
- package/src/claim-operations.js +138 -0
- package/src/claim-publication.js +739 -0
- package/src/claim-request.js +140 -0
- package/src/claim-store.js +198 -0
- package/src/cli.js +1130 -0
- package/src/dependencies.js +3 -0
- package/src/git-reconciliation.js +62 -0
- package/src/ledger.js +296 -0
- package/src/mint.js +32 -0
- package/src/mutation.js +1979 -0
- package/src/namespace.js +35 -0
- package/src/ready.js +85 -0
- package/src/request.js +246 -0
- package/src/schema-migration.js +300 -0
- package/src/validate.js +1208 -0
package/src/namespace.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const NAMESPACE = /^wbns_[a-f0-9]{32}$/;
|
|
6
|
+
|
|
7
|
+
function namespaceFile(ledgerDirectory) {
|
|
8
|
+
return path.join(path.resolve(ledgerDirectory), '.wowbagger', 'namespace');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readNamespace(ledgerDirectory) {
|
|
12
|
+
try {
|
|
13
|
+
const text = await readFile(namespaceFile(ledgerDirectory), 'utf8');
|
|
14
|
+
const value = text.trim();
|
|
15
|
+
return NAMESPACE.test(value) ? value : null;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error?.code === 'ENOENT') return null;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function provisionNamespace(ledgerDirectory) {
|
|
23
|
+
const existing = await readNamespace(ledgerDirectory);
|
|
24
|
+
if (existing) return { namespace: existing, created: false };
|
|
25
|
+
await mkdir(path.dirname(namespaceFile(ledgerDirectory)), { recursive: true });
|
|
26
|
+
const namespace = `wbns_${randomBytes(16).toString('hex')}`;
|
|
27
|
+
const handle = await open(namespaceFile(ledgerDirectory), 'wx');
|
|
28
|
+
try {
|
|
29
|
+
await handle.writeFile(`${namespace}\n`, 'utf8');
|
|
30
|
+
await handle.sync();
|
|
31
|
+
} finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
return { namespace, created: true };
|
|
35
|
+
}
|
package/src/ready.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isDependencySatisfied } from './dependencies.js';
|
|
2
|
+
|
|
3
|
+
export function selectReady(items, asOf) {
|
|
4
|
+
const byId = new Map(items.map((item) => [item.data.id, item]));
|
|
5
|
+
const ancestorsBacklogById = new Map();
|
|
6
|
+
|
|
7
|
+
return items
|
|
8
|
+
.filter((item) => isReady(item, byId, ancestorsBacklogById, asOf))
|
|
9
|
+
.sort((left, right) => comparePriority(left.data, right.data)
|
|
10
|
+
|| compareText(left.data.created, right.data.created)
|
|
11
|
+
|| compareText(left.data.id, right.data.id))
|
|
12
|
+
.map((item) => item.data.id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function compareText(left, right) {
|
|
16
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Items carrying a priority sort before items without one, then by ascending
|
|
20
|
+
// priority. The core reports the supplied priority; it never invents or
|
|
21
|
+
// recalculates one.
|
|
22
|
+
function comparePriority(left, right) {
|
|
23
|
+
const hasLeft = typeof left.priority === 'number';
|
|
24
|
+
const hasRight = typeof right.priority === 'number';
|
|
25
|
+
|
|
26
|
+
if (hasLeft !== hasRight) {
|
|
27
|
+
return hasLeft ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
if (!hasLeft) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
return left.priority - right.priority;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isReady(item, byId, ancestorsBacklogById, asOf) {
|
|
36
|
+
const { data } = item;
|
|
37
|
+
|
|
38
|
+
return data.kind === 'task'
|
|
39
|
+
&& data.status === 'backlog'
|
|
40
|
+
&& (!data.snoozed_until || data.snoozed_until <= asOf)
|
|
41
|
+
&& Array.isArray(data.depends_on)
|
|
42
|
+
&& dependenciesAreSatisfied(data, byId)
|
|
43
|
+
&& ancestorsAreBacklog(data, byId, ancestorsBacklogById);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dependenciesAreSatisfied(data, byId) {
|
|
47
|
+
if (data.schema_version === 1) {
|
|
48
|
+
return data.depends_on.length === 0;
|
|
49
|
+
}
|
|
50
|
+
return data.depends_on.every((id) => isDependencySatisfied(byId.get(id)?.data.status));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ancestorsAreBacklog(data, byId, ancestorsBacklogById) {
|
|
54
|
+
let parentId = data.parent;
|
|
55
|
+
const visited = new Set();
|
|
56
|
+
const path = [];
|
|
57
|
+
let result = true;
|
|
58
|
+
|
|
59
|
+
while (parentId) {
|
|
60
|
+
if (ancestorsBacklogById.has(parentId)) {
|
|
61
|
+
result = ancestorsBacklogById.get(parentId);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (visited.has(parentId)) {
|
|
66
|
+
result = false;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
visited.add(parentId);
|
|
70
|
+
path.push(parentId);
|
|
71
|
+
|
|
72
|
+
const parent = byId.get(parentId);
|
|
73
|
+
if (!parent || parent.data.status !== 'backlog') {
|
|
74
|
+
result = false;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
parentId = parent.data.parent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const id of path) {
|
|
81
|
+
ancestorsBacklogById.set(id, result);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
package/src/request.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { TextDecoder } from 'node:util';
|
|
2
|
+
|
|
3
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
4
|
+
|
|
5
|
+
export function parseJsonRequest(bytes) {
|
|
6
|
+
let source;
|
|
7
|
+
try {
|
|
8
|
+
source = UTF8.decode(bytes);
|
|
9
|
+
} catch {
|
|
10
|
+
return invalidJson('invalid-utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const parser = new JsonParser(source);
|
|
15
|
+
const value = parser.parse();
|
|
16
|
+
return { value, issues: parser.issues };
|
|
17
|
+
} catch {
|
|
18
|
+
return invalidJson();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function pointer(parts) {
|
|
23
|
+
return parts.length === 0 ? '' : `/${parts.map(escapePointerPart).join('/')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sortIssues(issues) {
|
|
27
|
+
return [...issues].sort((left, right) => compareText(left.path, right.path)
|
|
28
|
+
|| compareText(left.code, right.code)
|
|
29
|
+
|| compareText(left.message, right.message));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class JsonNumber {
|
|
33
|
+
constructor(source) {
|
|
34
|
+
this.source = source;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `parseJsonRequest` boxes every JSON number as a JsonNumber and builds every
|
|
39
|
+
// object with a null prototype, but every consumer downstream of it compares
|
|
40
|
+
// against plain JS values. This rebuilds the parsed tree into ordinary
|
|
41
|
+
// objects and arrays with the numbers unwrapped.
|
|
42
|
+
//
|
|
43
|
+
// The rebuild MUST use `Object.fromEntries` (or another define-not-assign
|
|
44
|
+
// path). A `normalized[key] = …` loop invokes Object.prototype's `__proto__`
|
|
45
|
+
// setter for the key `__proto__`, which installs the value as the new
|
|
46
|
+
// object's prototype and erases it as an own key — so a caller-supplied
|
|
47
|
+
// `__proto__` member disappears from `Object.keys` and slips past every
|
|
48
|
+
// exact-member schema check downstream.
|
|
49
|
+
export function normalizeJsonValue(value) {
|
|
50
|
+
if (value instanceof JsonNumber) {
|
|
51
|
+
return Number(value.source);
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.map(normalizeJsonValue);
|
|
55
|
+
}
|
|
56
|
+
if (value !== null && typeof value === 'object') {
|
|
57
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeJsonValue(entry)]));
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function invalidJson(inputDiagnostic = null) {
|
|
63
|
+
return {
|
|
64
|
+
value: null,
|
|
65
|
+
inputDiagnostic,
|
|
66
|
+
issues: [{
|
|
67
|
+
path: '',
|
|
68
|
+
code: 'invalid-json',
|
|
69
|
+
message: 'Request input must be valid JSON.',
|
|
70
|
+
}],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class JsonParser {
|
|
75
|
+
constructor(source) {
|
|
76
|
+
this.source = source;
|
|
77
|
+
this.index = 0;
|
|
78
|
+
this.issues = [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
parse() {
|
|
82
|
+
this.skipWhitespace();
|
|
83
|
+
const value = this.value([]);
|
|
84
|
+
this.skipWhitespace();
|
|
85
|
+
if (this.index !== this.source.length) {
|
|
86
|
+
throw new Error('trailing JSON input');
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
value(location) {
|
|
92
|
+
this.skipWhitespace();
|
|
93
|
+
const character = this.source[this.index];
|
|
94
|
+
if (character === '{') {
|
|
95
|
+
return this.object(location);
|
|
96
|
+
}
|
|
97
|
+
if (character === '[') {
|
|
98
|
+
return this.array(location);
|
|
99
|
+
}
|
|
100
|
+
if (character === '"') {
|
|
101
|
+
return this.string();
|
|
102
|
+
}
|
|
103
|
+
if (this.source.startsWith('true', this.index)) {
|
|
104
|
+
this.index += 4;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (this.source.startsWith('false', this.index)) {
|
|
108
|
+
this.index += 5;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (this.source.startsWith('null', this.index)) {
|
|
112
|
+
this.index += 4;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return this.number();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
object(location) {
|
|
119
|
+
this.expect('{');
|
|
120
|
+
this.skipWhitespace();
|
|
121
|
+
const object = Object.create(null);
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
if (this.consume('}')) {
|
|
124
|
+
return object;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
while (true) {
|
|
128
|
+
this.skipWhitespace();
|
|
129
|
+
if (this.source[this.index] !== '"') {
|
|
130
|
+
throw new Error('object key is not a string');
|
|
131
|
+
}
|
|
132
|
+
const key = this.string();
|
|
133
|
+
this.skipWhitespace();
|
|
134
|
+
this.expect(':');
|
|
135
|
+
const childLocation = [...location, key];
|
|
136
|
+
const value = this.value(childLocation);
|
|
137
|
+
if (seen.has(key)) {
|
|
138
|
+
this.issues.push({
|
|
139
|
+
path: pointer(childLocation),
|
|
140
|
+
code: 'duplicate-key',
|
|
141
|
+
message: `JSON member ${key} must not be repeated.`,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
seen.add(key);
|
|
145
|
+
object[key] = value;
|
|
146
|
+
}
|
|
147
|
+
this.skipWhitespace();
|
|
148
|
+
if (this.consume('}')) {
|
|
149
|
+
return object;
|
|
150
|
+
}
|
|
151
|
+
this.expect(',');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
array(location) {
|
|
156
|
+
this.expect('[');
|
|
157
|
+
this.skipWhitespace();
|
|
158
|
+
const values = [];
|
|
159
|
+
if (this.consume(']')) {
|
|
160
|
+
return values;
|
|
161
|
+
}
|
|
162
|
+
while (true) {
|
|
163
|
+
values.push(this.value([...location, String(values.length)]));
|
|
164
|
+
this.skipWhitespace();
|
|
165
|
+
if (this.consume(']')) {
|
|
166
|
+
return values;
|
|
167
|
+
}
|
|
168
|
+
this.expect(',');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
string() {
|
|
173
|
+
const start = this.index;
|
|
174
|
+
this.expect('"');
|
|
175
|
+
let escaped = false;
|
|
176
|
+
while (this.index < this.source.length) {
|
|
177
|
+
const character = this.source[this.index];
|
|
178
|
+
this.index += 1;
|
|
179
|
+
if (escaped) {
|
|
180
|
+
escaped = false;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (character === '\\') {
|
|
184
|
+
escaped = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (character === '"') {
|
|
188
|
+
return JSON.parse(this.source.slice(start, this.index));
|
|
189
|
+
}
|
|
190
|
+
if (character.charCodeAt(0) < 0x20) {
|
|
191
|
+
throw new Error('unescaped control character');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw new Error('unterminated JSON string');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
number() {
|
|
198
|
+
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.source.slice(this.index));
|
|
199
|
+
if (!match) {
|
|
200
|
+
throw new Error('invalid JSON value');
|
|
201
|
+
}
|
|
202
|
+
this.index += match[0].length;
|
|
203
|
+
return new JsonNumber(match[0]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
skipWhitespace() {
|
|
207
|
+
while (this.index < this.source.length && /[\u0009\u000a\u000d\u0020]/.test(this.source[this.index])) {
|
|
208
|
+
this.index += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
consume(character) {
|
|
213
|
+
if (this.source[this.index] !== character) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
this.index += 1;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
expect(character) {
|
|
221
|
+
if (!this.consume(character)) {
|
|
222
|
+
throw new Error(`expected ${character}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function escapePointerPart(part) {
|
|
228
|
+
return part.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function compareText(left, right) {
|
|
232
|
+
const leftIterator = left[Symbol.iterator]();
|
|
233
|
+
const rightIterator = right[Symbol.iterator]();
|
|
234
|
+
while (true) {
|
|
235
|
+
const leftValue = leftIterator.next();
|
|
236
|
+
const rightValue = rightIterator.next();
|
|
237
|
+
if (leftValue.done || rightValue.done) {
|
|
238
|
+
return leftValue.done === rightValue.done ? 0 : leftValue.done ? -1 : 1;
|
|
239
|
+
}
|
|
240
|
+
const leftPoint = leftValue.value.codePointAt(0);
|
|
241
|
+
const rightPoint = rightValue.value.codePointAt(0);
|
|
242
|
+
if (leftPoint !== rightPoint) {
|
|
243
|
+
return leftPoint < rightPoint ? -1 : 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, readdir, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
|
|
7
|
+
import { loadLedger, parseLedgerItemSource } from './ledger.js';
|
|
8
|
+
import { validateLedger } from './validate.js';
|
|
9
|
+
|
|
10
|
+
const MAINTENANCE_NOTICE = 'NOTICE: This is a quiesced-window maintenance operation. Take a backup before --apply; recovery uses that backup and Git, not the mutation contract.';
|
|
11
|
+
const HISTORY_NOTICE = 'NOTICE: Schema 1 cleanup history is unrecoverable. Prerequisites previously moved from depends_on to related stay there; no dependency is inferred.';
|
|
12
|
+
const UTF8_BYTE_ORDER_MARK = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
13
|
+
|
|
14
|
+
export async function runSchema2MigrationCli(argumentsList, streams = {}) {
|
|
15
|
+
const stdout = streams.stdout ?? process.stdout;
|
|
16
|
+
const options = parseArguments(argumentsList);
|
|
17
|
+
|
|
18
|
+
stdout.write(`${MAINTENANCE_NOTICE}\n`);
|
|
19
|
+
stdout.write(`${HISTORY_NOTICE}\n`);
|
|
20
|
+
const result = await migrateSchema2(options.ledger, {
|
|
21
|
+
apply: options.apply,
|
|
22
|
+
onItem: async (change) => {
|
|
23
|
+
stdout.write(`CHANGED ${change.path} (${change.id}): schema_version 1 -> 2\n`);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (options.apply) {
|
|
28
|
+
stdout.write(`Summary: ${result.changes.length} ${itemWord(result.changes.length)} changed.\n`);
|
|
29
|
+
stdout.write('Validation: schema version 2 passed.\n');
|
|
30
|
+
} else {
|
|
31
|
+
for (const change of result.changes) {
|
|
32
|
+
stdout.write(`WOULD CHANGE ${change.path} (${change.id}): schema_version 1 -> 2\n`);
|
|
33
|
+
}
|
|
34
|
+
stdout.write(`Summary: ${result.changes.length} ${itemWord(result.changes.length)} would change; 0 files written (dry run).\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function itemWord(count) {
|
|
39
|
+
return count === 1 ? 'item' : 'items';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function migrateSchema2(ledgerDirectory, { apply = false, onItem = async () => {} } = {}) {
|
|
43
|
+
const ledger = await loadLedger(ledgerDirectory);
|
|
44
|
+
const inputValidation = validateLedger(ledger);
|
|
45
|
+
const schemaVersions = new Set(ledger.items.map((item) => item.data.schema_version));
|
|
46
|
+
if (schemaVersions.has(1) && schemaVersions.has(2)) {
|
|
47
|
+
throw new SchemaMigrationError(
|
|
48
|
+
'mixed-schema-versions',
|
|
49
|
+
'The ledger contains schema versions 1 and 2. This is a partial migration state. Restore the complete ledger from the pre-migration backup or Git, validate schema version 1, then rerun the dry run.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (inputValidation.valid && ledger.items.length === 0) {
|
|
53
|
+
throw new SchemaMigrationError(
|
|
54
|
+
'empty-ledger',
|
|
55
|
+
'An empty ledger has no schema_version stamp to migrate and still defaults to schema version 1. No files were changed.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (ledger.items.length > 0 && schemaVersions.size === 1 && schemaVersions.has(2)) {
|
|
59
|
+
if (!inputValidation.valid) {
|
|
60
|
+
throw new SchemaMigrationError(
|
|
61
|
+
'invalid-schema-2',
|
|
62
|
+
'Every item is schema version 2, but the complete ledger is invalid. Repair the schema version 2 ledger or restore the pre-migration backup or Git. No files were changed.',
|
|
63
|
+
inputValidation.errors,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw new SchemaMigrationError(
|
|
67
|
+
'already-schema-2',
|
|
68
|
+
'Every item is already schema version 2. This tool will not run again. Validate the ledger as schema version 2; if validation fails, restore the pre-migration backup or Git.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!inputValidation.valid) {
|
|
72
|
+
throw new SchemaMigrationError(
|
|
73
|
+
'invalid-schema-1',
|
|
74
|
+
'The ledger must validate completely as schema version 1 before migration. No files were changed.',
|
|
75
|
+
inputValidation.errors,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const heldLocks = await heldItemLocks(ledgerDirectory);
|
|
79
|
+
if (heldLocks.length > 0) {
|
|
80
|
+
throw new SchemaMigrationError(
|
|
81
|
+
'lock-held',
|
|
82
|
+
`Item locks are held under the ledger: ${heldLocks.join(', ')}. The migration requires a quiesced window. Stop all writers and resolve the locks through audited manual recovery before rerunning the dry run. No files were changed.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const changes = ledger.items.map((item) => {
|
|
86
|
+
const source = schema2Source(item.source);
|
|
87
|
+
return {
|
|
88
|
+
id: item.data.id,
|
|
89
|
+
path: item.path,
|
|
90
|
+
file: item.file,
|
|
91
|
+
before: item.bytes,
|
|
92
|
+
after: migratedBytes(item.bytes, source),
|
|
93
|
+
source,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const candidate = {
|
|
98
|
+
errors: [...ledger.errors],
|
|
99
|
+
items: changes.map((change, index) => candidateItem(ledger.items[index], change)),
|
|
100
|
+
};
|
|
101
|
+
const validation = validateLedger(candidate);
|
|
102
|
+
if (!validation.valid || candidate.items.some((item) => item.data.schema_version !== 2)) {
|
|
103
|
+
throw new Error('The planned schema version 2 ledger does not validate.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let completed = 0;
|
|
107
|
+
if (apply) {
|
|
108
|
+
for (const change of changes) {
|
|
109
|
+
try {
|
|
110
|
+
await atomicRewrite(change);
|
|
111
|
+
} catch {
|
|
112
|
+
const state = completed === 0
|
|
113
|
+
? 'No item write completed.'
|
|
114
|
+
: 'The ledger now contains mixed schema versions.';
|
|
115
|
+
throw new SchemaMigrationError(
|
|
116
|
+
'partial-write-failed',
|
|
117
|
+
`Migration stopped at ${change.path} after ${completed} of ${changes.length} item writes. ${state} Restore the complete ledger from the pre-migration backup or Git before rerunning.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
completed += 1;
|
|
121
|
+
await onItem(change);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const migratedLedger = await loadLedger(ledgerDirectory);
|
|
125
|
+
const postValidation = validateLedger(migratedLedger);
|
|
126
|
+
const uniformSchema2 = migratedLedger.items.length > 0
|
|
127
|
+
&& migratedLedger.items.every((item) => item.data.schema_version === 2);
|
|
128
|
+
if (!postValidation.valid || !uniformSchema2) {
|
|
129
|
+
throw new SchemaMigrationError(
|
|
130
|
+
'post-validation-failed',
|
|
131
|
+
`The post-migration ledger did not validate as schema version 2 after ${completed} of ${changes.length} item writes. Restore the pre-migration backup or Git before any rerun.`,
|
|
132
|
+
postValidation.errors,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { apply, changes, completed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function heldItemLocks(ledgerDirectory) {
|
|
141
|
+
const lockDirectory = path.join(path.resolve(ledgerDirectory), '.wowbagger-locks');
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = await readdir(lockDirectory, { withFileTypes: true });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error?.code === 'ENOENT') {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
throw new SchemaMigrationError(
|
|
150
|
+
'lock-state-unknown',
|
|
151
|
+
'The item-lock directory could not be inspected. Quiescence is not established. No files were changed.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return entries
|
|
155
|
+
.filter((entry) => entry.name.endsWith('.lock'))
|
|
156
|
+
.map((entry) => `.wowbagger-locks/${entry.name}`)
|
|
157
|
+
.sort();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function formatSchemaMigrationError(error) {
|
|
161
|
+
if (!(error instanceof SchemaMigrationError)) {
|
|
162
|
+
return `ERROR: ${error.message}\n`;
|
|
163
|
+
}
|
|
164
|
+
const diagnostics = error.diagnostics
|
|
165
|
+
.map((entry) => `${entry.path} ${entry.field} [${entry.code}]: ${entry.message}`)
|
|
166
|
+
.join('\n');
|
|
167
|
+
return `ERROR [${error.code}]: ${error.message}\n${diagnostics ? `${diagnostics}\n` : ''}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class SchemaMigrationError extends Error {
|
|
171
|
+
constructor(code, message, diagnostics = []) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.code = code;
|
|
174
|
+
this.diagnostics = diagnostics;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function atomicRewrite(change) {
|
|
179
|
+
const sourceHandle = await open(change.file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
180
|
+
let sourceStat;
|
|
181
|
+
let current;
|
|
182
|
+
try {
|
|
183
|
+
sourceStat = await sourceHandle.stat();
|
|
184
|
+
current = await sourceHandle.readFile();
|
|
185
|
+
} finally {
|
|
186
|
+
await sourceHandle.close();
|
|
187
|
+
}
|
|
188
|
+
if (!sourceStat.isFile() || !current.equals(change.before)) {
|
|
189
|
+
throw new Error(`Item changed after migration preflight: ${change.path}.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const temporary = path.join(
|
|
193
|
+
path.dirname(change.file),
|
|
194
|
+
`.wowbagger-schema-2-${process.pid}-${randomUUID()}.tmp`,
|
|
195
|
+
);
|
|
196
|
+
let temporaryHandle;
|
|
197
|
+
try {
|
|
198
|
+
temporaryHandle = await open(temporary, 'wx', sourceStat.mode & 0o777);
|
|
199
|
+
await temporaryHandle.writeFile(change.after);
|
|
200
|
+
await temporaryHandle.chmod(sourceStat.mode & 0o777);
|
|
201
|
+
await temporaryHandle.sync();
|
|
202
|
+
await temporaryHandle.close();
|
|
203
|
+
temporaryHandle = undefined;
|
|
204
|
+
await rename(temporary, change.file);
|
|
205
|
+
} finally {
|
|
206
|
+
await temporaryHandle?.close().catch(() => {});
|
|
207
|
+
await unlink(temporary).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function candidateItem(item, change) {
|
|
212
|
+
const parsed = parseLedgerItemSource(change.source);
|
|
213
|
+
if (parsed.error) {
|
|
214
|
+
return { ...item, data: {}, source: change.source, bytes: change.after };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
...item,
|
|
218
|
+
data: parsed.data,
|
|
219
|
+
body: parsed.body,
|
|
220
|
+
source: change.source,
|
|
221
|
+
bytes: change.after,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function schema2Source(source) {
|
|
226
|
+
const bounds = frontmatterBounds(source);
|
|
227
|
+
const document = parseDocument(source.slice(bounds.start, bounds.end), {
|
|
228
|
+
prettyErrors: false,
|
|
229
|
+
schema: 'core',
|
|
230
|
+
uniqueKeys: true,
|
|
231
|
+
});
|
|
232
|
+
const schemaVersion = document.get('schema_version', true);
|
|
233
|
+
if (!schemaVersion?.range) {
|
|
234
|
+
throw new Error('The schema_version scalar could not be located.');
|
|
235
|
+
}
|
|
236
|
+
const start = bounds.start + schemaVersion.range[0];
|
|
237
|
+
const end = bounds.start + schemaVersion.range[1];
|
|
238
|
+
return `${source.slice(0, start)}2${source.slice(end)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function migratedBytes(before, source) {
|
|
242
|
+
const bytes = Buffer.from(source, 'utf8');
|
|
243
|
+
return before.subarray(0, UTF8_BYTE_ORDER_MARK.length).equals(UTF8_BYTE_ORDER_MARK)
|
|
244
|
+
? Buffer.concat([UTF8_BYTE_ORDER_MARK, bytes])
|
|
245
|
+
: bytes;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function frontmatterBounds(source) {
|
|
249
|
+
const opening = nextLine(source, 0);
|
|
250
|
+
if (opening.content !== '---' || opening.next === null) {
|
|
251
|
+
throw new Error('The item frontmatter could not be located.');
|
|
252
|
+
}
|
|
253
|
+
const start = opening.next;
|
|
254
|
+
let cursor = start;
|
|
255
|
+
while (cursor < source.length) {
|
|
256
|
+
const line = nextLine(source, cursor);
|
|
257
|
+
if (line.content === '---') {
|
|
258
|
+
return { start, end: cursor };
|
|
259
|
+
}
|
|
260
|
+
if (line.next === null) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
cursor = line.next;
|
|
264
|
+
}
|
|
265
|
+
throw new Error('The item frontmatter could not be located.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function nextLine(source, start) {
|
|
269
|
+
const lf = source.indexOf('\n', start);
|
|
270
|
+
if (lf === -1) {
|
|
271
|
+
return { content: source.slice(start), next: null };
|
|
272
|
+
}
|
|
273
|
+
const carriageReturn = lf > start && source[lf - 1] === '\r';
|
|
274
|
+
return {
|
|
275
|
+
content: source.slice(start, carriageReturn ? lf - 1 : lf),
|
|
276
|
+
next: lf + 1,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseArguments(argumentsList) {
|
|
281
|
+
let ledger;
|
|
282
|
+
let apply = false;
|
|
283
|
+
|
|
284
|
+
for (let index = 0; index < argumentsList.length; index += 1) {
|
|
285
|
+
const argument = argumentsList[index];
|
|
286
|
+
if (argument === '--ledger' && ledger === undefined && index + 1 < argumentsList.length) {
|
|
287
|
+
ledger = argumentsList[index + 1];
|
|
288
|
+
index += 1;
|
|
289
|
+
} else if (argument === '--apply' && !apply) {
|
|
290
|
+
apply = true;
|
|
291
|
+
} else {
|
|
292
|
+
throw new Error('Usage: node scripts/migrate-schema-2.js --ledger <dir> [--apply]');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (ledger === undefined) {
|
|
297
|
+
throw new Error('Usage: node scripts/migrate-schema-2.js --ledger <dir> [--apply]');
|
|
298
|
+
}
|
|
299
|
+
return { ledger, apply };
|
|
300
|
+
}
|