mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-06-18 02:08:46 -04:00
811 lines
22 KiB
TypeScript
811 lines
22 KiB
TypeScript
/**
|
|
* PCD conflict tests: regular expressions.
|
|
*/
|
|
|
|
import { assert, assertEquals, assertExists } from '@std/assert';
|
|
import { startServer, stopServer } from '$test-harness/server.ts';
|
|
import { openDb } from '$test-harness/db.ts';
|
|
import { run, setup, teardown, test } from '$test-harness/runner.ts';
|
|
import { PORTS } from '$test-harness/ports.ts';
|
|
import { base } from '../harness/fixtures.ts';
|
|
import {
|
|
compilePcd,
|
|
insertOp,
|
|
opCheckpoint,
|
|
parseMetadata,
|
|
queryOpsSince,
|
|
seedBase,
|
|
setupPcd,
|
|
type ConflictStrategy,
|
|
type OpRow,
|
|
type PcdTestContext,
|
|
type SeedOperation
|
|
} from '../harness/pcd.ts';
|
|
import { write } from '../harness/write.ts';
|
|
|
|
const PORT = PORTS.pcd.conflictsRegex;
|
|
const ORIGIN = `http://localhost:${PORT}`;
|
|
const STRATEGIES: ConflictStrategy[] = ['ask', 'align', 'override'];
|
|
|
|
type LatestHistory = {
|
|
status: string;
|
|
conflict_reason: string | null;
|
|
};
|
|
|
|
type RegexRow = {
|
|
name: string;
|
|
pattern: string;
|
|
description: string | null;
|
|
regex101_id: string | null;
|
|
};
|
|
|
|
type RegexTagRow = {
|
|
regular_expression_name: string;
|
|
tag_name: string;
|
|
};
|
|
|
|
let counter = 0;
|
|
|
|
setup(async () => {
|
|
await startServer(PORT, { AUTH: 'off', ORIGIN }, 'preview');
|
|
});
|
|
|
|
teardown(async () => {
|
|
await stopServer(PORT);
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Split Conflict', pattern='\bsplit\b', description='Original'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* pattern = '\buser\b'
|
|
* description = 'User edit'
|
|
*
|
|
* Upstream
|
|
* Published base op changes:
|
|
* pattern '\bsplit\b' -> '\bupstream\b'
|
|
*
|
|
* Expect
|
|
* - pattern op is conflicted_pending / dropped / superseded by strategy
|
|
* - description op applies cleanly for every strategy (no conflict)
|
|
* - final pattern is '\buser\b' only for override, otherwise '\bupstream\b'
|
|
* - final description is 'User edit' for every strategy
|
|
*/
|
|
test('split scalar conflicts resolve per field by strategy', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'split-scalar', [
|
|
base.regex({
|
|
name: 'Split Conflict',
|
|
pattern: '\\bsplit\\b',
|
|
description: 'Original'
|
|
})
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'Split Conflict',
|
|
pattern: '\\buser\\b',
|
|
description: 'User edit'
|
|
});
|
|
|
|
seedUpstream(
|
|
ctx,
|
|
upstreamUpdate('Split Conflict', {
|
|
pattern: { from: '\\bsplit\\b', to: '\\bupstream\\b' }
|
|
})
|
|
);
|
|
await compilePcd(ctx);
|
|
|
|
const ops = userOpsSince(ctx, checkpoint);
|
|
const patternOp = firstOpForChangedFields(ops, ['pattern']);
|
|
const descriptionOp = firstOpForChangedFields(ops, ['description']);
|
|
|
|
assertStrategyOutcome(ctx, patternOp, strategy, 'guard_mismatch');
|
|
assertLatestHistory(ctx, descriptionOp, 'applied');
|
|
|
|
const row = assertRegex(ctx, 'Split Conflict');
|
|
assertEquals(row.pattern, strategy === 'override' ? '\\buser\\b' : '\\bupstream\\b');
|
|
assertEquals(row.description, 'User edit');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Old Regex', pattern='\bold\b'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* name = 'User Regex'
|
|
*
|
|
* Upstream
|
|
* Published base rename changes:
|
|
* name 'Old Regex' -> 'Upstream Regex'
|
|
*
|
|
* Expect
|
|
* - rename op is conflicted_pending / dropped / superseded by strategy
|
|
* - override final row is named 'User Regex'
|
|
* - otherwise final row is named 'Upstream Regex'
|
|
*/
|
|
test('rename conflict follows upstream rename by strategy', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'rename', [
|
|
base.regex({ name: 'Old Regex', pattern: '\\bold\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'User Regex',
|
|
pattern: '\\bold\\b'
|
|
});
|
|
|
|
seedUpstream(ctx, upstreamRename('Old Regex', 'Upstream Regex'));
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForChangedFields(opsSince(ctx, checkpoint), ['name']);
|
|
assertStrategyOutcome(ctx, op, strategy, 'guard_mismatch');
|
|
|
|
if (strategy === 'override') {
|
|
assertRegex(ctx, 'User Regex');
|
|
assertNoRegex(ctx, 'Upstream Regex');
|
|
} else {
|
|
assertRegex(ctx, 'Upstream Regex');
|
|
assertNoRegex(ctx, 'User Regex');
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Migrates old 3.3.
|
|
*
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Rename Pattern', pattern='\boriginal\b'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* pattern = '\blocal\b'
|
|
*
|
|
* Upstream
|
|
* Published base rename changes:
|
|
* name 'Rename Pattern' -> 'Rename Pattern Upstream'
|
|
*
|
|
* Expect
|
|
* - pattern op conflicts by strategy
|
|
* - final row keeps the upstream name
|
|
* - final pattern is local only for override, otherwise original
|
|
*/
|
|
test('pattern conflict follows upstream rename by strategy', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'pattern-upstream-rename', [
|
|
base.regex({ name: 'Rename Pattern', pattern: '\\boriginal\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'Rename Pattern',
|
|
pattern: '\\blocal\\b'
|
|
});
|
|
|
|
seedUpstream(ctx, upstreamRename('Rename Pattern', 'Rename Pattern Upstream'));
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForChangedFields(opsSince(ctx, checkpoint), ['pattern']);
|
|
assertStrategyOutcome(ctx, op, strategy, 'guard_mismatch');
|
|
|
|
const row = assertRegex(ctx, 'Rename Pattern Upstream');
|
|
assertEquals(row.pattern, strategy === 'override' ? '\\blocal\\b' : '\\boriginal\\b');
|
|
assertNoRegex(ctx, 'Rename Pattern');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Migrates old 3.4.
|
|
*
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Description Conflict', description='Original'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* description = 'Local description'
|
|
*
|
|
* Upstream
|
|
* Published base op changes:
|
|
* description 'Original' -> 'Upstream description'
|
|
*
|
|
* Expect
|
|
* - description op conflicts by strategy
|
|
* - final description is local only for override, otherwise upstream
|
|
*/
|
|
test('description conflict resolves by strategy', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'description-conflict', [
|
|
base.regex({
|
|
name: 'Description Conflict',
|
|
pattern: '\\bdescription\\b',
|
|
description: 'Original'
|
|
})
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'Description Conflict',
|
|
pattern: '\\bdescription\\b',
|
|
description: 'Local description'
|
|
});
|
|
|
|
seedUpstream(
|
|
ctx,
|
|
upstreamUpdate('Description Conflict', {
|
|
description: { from: 'Original', to: 'Upstream description' }
|
|
})
|
|
);
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForChangedFields(opsSince(ctx, checkpoint), ['description']);
|
|
assertStrategyOutcome(ctx, op, strategy, 'guard_mismatch');
|
|
|
|
const row = assertRegex(ctx, 'Description Conflict');
|
|
assertEquals(
|
|
row.description,
|
|
strategy === 'override' ? 'Local description' : 'Upstream description'
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Empty PCD.
|
|
*
|
|
* User
|
|
* POST create regex:
|
|
* name='Create Conflict', pattern='\buser\b'
|
|
*
|
|
* Upstream
|
|
* Published base create:
|
|
* name='Create Conflict', pattern='\bupstream\b'
|
|
*
|
|
* Expect
|
|
* - user create op is conflicted_pending / dropped / superseded by strategy
|
|
* - final pattern is '\buser\b' only for override, otherwise '\bupstream\b'
|
|
*/
|
|
test('create duplicate conflict resolves by strategy', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await newScenario(strategy, 'create-duplicate');
|
|
await compilePcd(ctx);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.create(ctx, {
|
|
name: 'Create Conflict',
|
|
pattern: '\\buser\\b'
|
|
});
|
|
|
|
seedUpstream(ctx, base.regex({ name: 'Create Conflict', pattern: '\\bupstream\\b' }));
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForOperation(opsSince(ctx, checkpoint), 'create');
|
|
assertStrategyOutcome(ctx, op, strategy, 'duplicate_key');
|
|
|
|
const row = assertRegex(ctx, 'Create Conflict');
|
|
assertEquals(row.pattern, strategy === 'override' ? '\\buser\\b' : '\\bupstream\\b');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Already Deleted', pattern='\bgone\b'
|
|
*
|
|
* User
|
|
* POST delete the regex.
|
|
*
|
|
* Upstream
|
|
* Published base delete removes the same row.
|
|
*
|
|
* Expect
|
|
* - all strategies: user delete auto-aligns/drops because the target is gone
|
|
* - final row is absent
|
|
*/
|
|
test('delete missing target via upstream delete auto-aligns', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'delete-missing-target', [
|
|
base.regex({ name: 'Already Deleted', pattern: '\\bgone\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.remove(ctx, 1);
|
|
|
|
seedUpstream(ctx, upstreamDelete('Already Deleted'));
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForOperation(opsSince(ctx, checkpoint), 'delete');
|
|
assertEquals(op.state, 'dropped');
|
|
assertLatestHistory(ctx, op, 'dropped', 'aligned');
|
|
assertNoRegex(ctx, 'Already Deleted');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Renamed Away', pattern='\brenamed\b'
|
|
*
|
|
* User
|
|
* POST delete the regex.
|
|
*
|
|
* Upstream
|
|
* Published base rename moves the row to 'Now Different'.
|
|
*
|
|
* Expect
|
|
* - the user delete now finds zero rows (its name guard misses the renamed
|
|
* row); conflict reason is missing_target because deleteTargetExists
|
|
* looks up by the original name
|
|
* - all strategies auto-align/drop the user delete
|
|
* - final cache contains 'Now Different' (the renamed row), not 'Renamed Away'
|
|
*/
|
|
test('delete missing target via upstream rename auto-aligns', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'delete-rename-missing', [
|
|
base.regex({ name: 'Renamed Away', pattern: '\\brenamed\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.remove(ctx, 1);
|
|
|
|
seedUpstream(ctx, upstreamRename('Renamed Away', 'Now Different'));
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForOperation(opsSince(ctx, checkpoint), 'delete');
|
|
assertEquals(op.state, 'dropped');
|
|
assertLatestHistory(ctx, op, 'dropped', 'aligned');
|
|
assertRegex(ctx, 'Now Different');
|
|
assertNoRegex(ctx, 'Renamed Away');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Migrates old 3.7.
|
|
*
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Tags No Conflict', pattern='\boriginal\b'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* tags = ['LocalTag']
|
|
*
|
|
* Upstream
|
|
* Published base op changes:
|
|
* pattern '\boriginal\b' -> '\bupstream\b'
|
|
*
|
|
* Expect
|
|
* - user tags op applies cleanly for every strategy
|
|
* - no pending conflicts remain
|
|
* - final regex has the local tag and upstream pattern
|
|
*/
|
|
test('tags-only update applies after upstream pattern change', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'tags-no-conflict', [
|
|
base.regex({ name: 'Tags No Conflict', pattern: '\\boriginal\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'Tags No Conflict',
|
|
pattern: '\\boriginal\\b',
|
|
tags: ['LocalTag']
|
|
});
|
|
|
|
seedUpstream(
|
|
ctx,
|
|
upstreamUpdate('Tags No Conflict', {
|
|
pattern: { from: '\\boriginal\\b', to: '\\bupstream\\b' }
|
|
})
|
|
);
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForChangedFields(opsSince(ctx, checkpoint), ['tags']);
|
|
assertEquals(op.state, 'published');
|
|
assertLatestHistory(ctx, op, 'applied');
|
|
assertNoPendingConflicts(ctx);
|
|
|
|
const row = assertRegex(ctx, 'Tags No Conflict');
|
|
assertEquals(row.pattern, '\\bupstream\\b');
|
|
assertEquals(compiledRegexTags(ctx, 'Tags No Conflict'), ['LocalTag']);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Convergent', pattern='\bold\b'
|
|
*
|
|
* User
|
|
* POST update changes:
|
|
* pattern '\bold\b' -> '\bsame\b'
|
|
*
|
|
* Upstream
|
|
* Published base op changes:
|
|
* pattern '\bold\b' -> '\bsame\b' (same target value)
|
|
*
|
|
* Expect (verifies the defaultFieldGuard auto-align rule)
|
|
* - user pattern op auto-aligns regardless of strategy: state='dropped',
|
|
* history.status='dropped', conflict_reason='aligned'.
|
|
* - final pattern is '\bsame\b' (both sides wanted the same end state, so
|
|
* there is nothing to actually conflict over).
|
|
*/
|
|
test('matching upstream value auto-aligns user op', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'auto-align-convergent', [
|
|
base.regex({ name: 'Convergent', pattern: '\\bold\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.update(ctx, 1, {
|
|
name: 'Convergent',
|
|
pattern: '\\bsame\\b'
|
|
});
|
|
|
|
seedUpstream(
|
|
ctx,
|
|
upstreamUpdate('Convergent', {
|
|
pattern: { from: '\\bold\\b', to: '\\bsame\\b' }
|
|
})
|
|
);
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForChangedFields(opsSince(ctx, checkpoint), ['pattern']);
|
|
assertEquals(op.state, 'dropped');
|
|
assertLatestHistory(ctx, op, 'dropped', 'aligned');
|
|
|
|
const row = assertRegex(ctx, 'Convergent');
|
|
assertEquals(row.pattern, '\\bsame\\b');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Context
|
|
* Base layer seeded with one regex via base.regex():
|
|
* name='Pattern Change', pattern='\boriginal\b'
|
|
*
|
|
* User
|
|
* POST delete the regex.
|
|
*
|
|
* Upstream
|
|
* Published base op changes pattern '\boriginal\b' -> '\bnew\b' on the same
|
|
* row (row stays named 'Pattern Change').
|
|
*
|
|
* Expect (verifies the name-only delete guard)
|
|
* - user delete op stays state='published' and history.status='applied' for
|
|
* every strategy (no conflict). The delete only guards by name, which
|
|
* still matches after upstream changed the pattern.
|
|
* - final row is absent.
|
|
*/
|
|
test('delete applies cleanly after upstream pattern change', async () => {
|
|
for (const strategy of STRATEGIES) {
|
|
const ctx = await seededScenario(strategy, 'delete-after-pattern-change', [
|
|
base.regex({ name: 'Pattern Change', pattern: '\\boriginal\\b' })
|
|
]);
|
|
const checkpoint = opCheckpoint(ctx);
|
|
|
|
await write.regex.remove(ctx, 1);
|
|
|
|
seedUpstream(
|
|
ctx,
|
|
upstreamUpdate('Pattern Change', {
|
|
pattern: { from: '\\boriginal\\b', to: '\\bnew\\b' }
|
|
})
|
|
);
|
|
await compilePcd(ctx);
|
|
|
|
const op = firstOpForOperation(opsSince(ctx, checkpoint), 'delete');
|
|
assertEquals(op.state, 'published');
|
|
assertLatestHistory(ctx, op, 'applied');
|
|
assertNoRegex(ctx, 'Pattern Change');
|
|
}
|
|
});
|
|
|
|
async function newScenario(strategy: ConflictStrategy, name: string): Promise<PcdTestContext> {
|
|
counter++;
|
|
return setupPcd({
|
|
port: PORT,
|
|
name: `pcd-conflict-regex-${counter}-${strategy}-${name}`,
|
|
conflictStrategy: strategy
|
|
});
|
|
}
|
|
|
|
async function seededScenario(
|
|
strategy: ConflictStrategy,
|
|
name: string,
|
|
operations: Array<string | SeedOperation>
|
|
): Promise<PcdTestContext> {
|
|
const ctx = await newScenario(strategy, name);
|
|
seedBase(ctx, operations);
|
|
await compilePcd(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
function seedUpstream(ctx: PcdTestContext, operation: SeedOperation): number {
|
|
return insertOp(ctx, {
|
|
...operation,
|
|
origin: 'base',
|
|
state: 'published',
|
|
source: 'repo'
|
|
});
|
|
}
|
|
|
|
function opsSince(ctx: PcdTestContext, checkpoint: number): OpRow[] {
|
|
return queryOpsSince(ctx, checkpoint, { origin: 'user' });
|
|
}
|
|
|
|
function userOpsSince(ctx: PcdTestContext, checkpoint: number): OpRow[] {
|
|
return opsSince(ctx, checkpoint);
|
|
}
|
|
|
|
function firstOpForOperation(ops: OpRow[], operation: string): OpRow {
|
|
const op = ops.find((candidate) => parseMetadata(candidate).operation === operation);
|
|
assertExists(op, `Expected a user ${operation} op`);
|
|
return op;
|
|
}
|
|
|
|
function firstOpForChangedFields(ops: OpRow[], fields: string[]): OpRow {
|
|
const expected = [...fields].sort();
|
|
const op = ops.find((candidate) => {
|
|
const actual = changedFields(candidate).sort();
|
|
return (
|
|
actual.length === expected.length && actual.every((field, index) => field === expected[index])
|
|
);
|
|
});
|
|
assertExists(op, `Expected a user op for ${fields.join(', ')}`);
|
|
return op;
|
|
}
|
|
|
|
function changedFields(op: OpRow): string[] {
|
|
const fields = parseMetadata(op).changed_fields;
|
|
if (!Array.isArray(fields)) return [];
|
|
return fields.filter((field): field is string => typeof field === 'string');
|
|
}
|
|
|
|
function assertStrategyOutcome(
|
|
ctx: PcdTestContext,
|
|
op: OpRow,
|
|
strategy: ConflictStrategy,
|
|
reason: string
|
|
): void {
|
|
if (strategy === 'ask') {
|
|
assertEquals(op.state, 'published');
|
|
assertLatestHistory(ctx, op, 'conflicted_pending', reason);
|
|
return;
|
|
}
|
|
|
|
if (strategy === 'align') {
|
|
assertEquals(op.state, 'dropped');
|
|
assertLatestHistory(ctx, op, 'dropped', 'aligned');
|
|
return;
|
|
}
|
|
|
|
assertEquals(op.state, 'superseded');
|
|
assert(op.superseded_by_op_id !== null, 'Expected override to link a replacement op');
|
|
assertLatestHistory(ctx, op, 'superseded');
|
|
}
|
|
|
|
function assertLatestHistory(
|
|
ctx: PcdTestContext,
|
|
op: OpRow,
|
|
status: string,
|
|
reason?: string
|
|
): void {
|
|
const history = latestHistory(ctx, op.id);
|
|
assertEquals(history.status, status);
|
|
if (reason !== undefined) {
|
|
assertEquals(history.conflict_reason, reason);
|
|
}
|
|
}
|
|
|
|
function latestHistory(ctx: PcdTestContext, opId: number): LatestHistory {
|
|
const db = openDb(ctx.dbPath);
|
|
try {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT status, conflict_reason
|
|
FROM pcd_op_history
|
|
WHERE database_id = ?
|
|
AND op_id = ?
|
|
ORDER BY applied_at DESC, id DESC
|
|
LIMIT 1`
|
|
)
|
|
.get(ctx.dbId, opId) as LatestHistory | undefined;
|
|
assertExists(row, `Expected latest history for op ${opId}`);
|
|
return row;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function assertRegex(ctx: PcdTestContext, name: string): RegexRow {
|
|
const row = compiledRegexes(ctx).find((regex) => regex.name === name);
|
|
assertExists(row, `Expected regex ${name}`);
|
|
return row;
|
|
}
|
|
|
|
function assertNoRegex(ctx: PcdTestContext, name: string): void {
|
|
const row = compiledRegexes(ctx).find((regex) => regex.name === name);
|
|
assertEquals(row, undefined);
|
|
}
|
|
|
|
function compiledRegexes(ctx: PcdTestContext): RegexRow[] {
|
|
return compiledRegexState(ctx).regexes;
|
|
}
|
|
|
|
function compiledRegexTags(ctx: PcdTestContext, name: string): string[] {
|
|
return compiledRegexState(ctx)
|
|
.tags.filter((tag) => tag.regular_expression_name === name)
|
|
.map((tag) => tag.tag_name);
|
|
}
|
|
|
|
function compiledRegexState(ctx: PcdTestContext): {
|
|
regexes: RegexRow[];
|
|
tags: RegexTagRow[];
|
|
} {
|
|
const source = openDb(ctx.dbPath);
|
|
const replay = openDb(':memory:');
|
|
|
|
try {
|
|
replay.exec('PRAGMA foreign_keys = ON');
|
|
replay.exec(Deno.readTextFileSync('docs/backend/0.schema.sql'));
|
|
|
|
const baseOps = source
|
|
.prepare(
|
|
`SELECT *
|
|
FROM pcd_ops
|
|
WHERE database_id = ?
|
|
AND origin = 'base'
|
|
AND state = 'published'
|
|
ORDER BY COALESCE(sequence, id), id`
|
|
)
|
|
.all(ctx.dbId) as OpRow[];
|
|
|
|
const userOps = source
|
|
.prepare(
|
|
`SELECT *
|
|
FROM pcd_ops
|
|
WHERE database_id = ?
|
|
AND origin = 'user'
|
|
AND state = 'published'
|
|
ORDER BY COALESCE(sequence, id), id`
|
|
)
|
|
.all(ctx.dbId) as OpRow[];
|
|
const histories = latestHistories(source, ctx.dbId);
|
|
|
|
for (const op of baseOps) {
|
|
replay.exec(op.sql);
|
|
}
|
|
for (const op of userOps) {
|
|
if (histories.get(op.id)?.status !== 'applied') continue;
|
|
replay.exec(op.sql);
|
|
}
|
|
|
|
const regexes = replay
|
|
.prepare(
|
|
`SELECT name, pattern, description, regex101_id
|
|
FROM regular_expressions
|
|
ORDER BY name`
|
|
)
|
|
.all() as RegexRow[];
|
|
const tags = replay
|
|
.prepare(
|
|
`SELECT regular_expression_name, tag_name
|
|
FROM regular_expression_tags
|
|
ORDER BY regular_expression_name, tag_name`
|
|
)
|
|
.all() as RegexTagRow[];
|
|
|
|
return { regexes, tags };
|
|
} finally {
|
|
replay.close();
|
|
source.close();
|
|
}
|
|
}
|
|
|
|
function assertNoPendingConflicts(ctx: PcdTestContext): void {
|
|
const db = openDb(ctx.dbPath);
|
|
try {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM pcd_op_history h
|
|
INNER JOIN (
|
|
SELECT op_id, MAX(id) AS max_id
|
|
FROM pcd_op_history
|
|
WHERE database_id = ?
|
|
GROUP BY op_id
|
|
) latest ON h.id = latest.max_id
|
|
WHERE h.status IN ('conflicted', 'conflicted_pending')`
|
|
)
|
|
.get(ctx.dbId) as { count: number };
|
|
assertEquals(row.count, 0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function latestHistories(
|
|
db: ReturnType<typeof openDb>,
|
|
databaseId: number
|
|
): Map<number, LatestHistory> {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT op_id, status, conflict_reason
|
|
FROM pcd_op_history
|
|
WHERE database_id = ?
|
|
ORDER BY applied_at ASC, id ASC`
|
|
)
|
|
.all(databaseId) as Array<LatestHistory & { op_id: number }>;
|
|
const latest = new Map<number, LatestHistory>();
|
|
for (const row of rows) {
|
|
latest.set(row.op_id, row);
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function upstreamUpdate(
|
|
name: string,
|
|
changes: Record<string, { from: unknown; to: unknown }>
|
|
): SeedOperation {
|
|
const fields = Object.keys(changes);
|
|
const setSql = fields.map((field) => `${field} = ${sqlValue(changes[field].to)}`).join(', ');
|
|
return {
|
|
sql: `UPDATE regular_expressions SET ${setSql} WHERE name = ${sqlValue(name)};`,
|
|
metadata: JSON.stringify({
|
|
operation: 'update',
|
|
entity: 'regular_expression',
|
|
name,
|
|
stable_key: { key: 'regular_expression_name', value: name },
|
|
changed_fields: fields
|
|
}),
|
|
desiredState: JSON.stringify(changes)
|
|
};
|
|
}
|
|
|
|
function upstreamRename(from: string, to: string): SeedOperation {
|
|
return {
|
|
sql: `UPDATE regular_expressions SET name = ${sqlValue(to)} WHERE name = ${sqlValue(from)};`,
|
|
metadata: JSON.stringify({
|
|
operation: 'update',
|
|
entity: 'regular_expression',
|
|
name: to,
|
|
previousName: from,
|
|
stable_key: { key: 'regular_expression_name', value: from },
|
|
changed_fields: ['name']
|
|
}),
|
|
desiredState: JSON.stringify({ name: { from, to } })
|
|
};
|
|
}
|
|
|
|
function upstreamDelete(name: string): SeedOperation {
|
|
return {
|
|
sql: `DELETE FROM regular_expressions WHERE name = ${sqlValue(name)};`,
|
|
metadata: JSON.stringify({
|
|
operation: 'delete',
|
|
entity: 'regular_expression',
|
|
name,
|
|
stable_key: { key: 'regular_expression_name', value: name },
|
|
changed_fields: ['deleted']
|
|
}),
|
|
desiredState: JSON.stringify({ deleted: true, name })
|
|
};
|
|
}
|
|
|
|
function sqlValue(value: unknown): string {
|
|
if (value === null || value === undefined) return 'NULL';
|
|
if (typeof value === 'number') return String(value);
|
|
if (typeof value === 'boolean') return value ? '1' : '0';
|
|
return `'${String(value).replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
await run();
|