2026-05-10 23:12:26 +02:00
|
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
|
|
|
import { DiffSetService } from '../../../src/context/ingest/diff-set.service.js';
|
|
|
|
|
import type { IngestDiffSummary, IngestTrigger } from '../../../src/context/ingest/types.js';
|
|
|
|
|
import type { IngestReportBody } from '../../../src/context/ingest/reports.js';
|
|
|
|
|
import { SqliteBundleIngestStore } from '../../../src/context/ingest/sqlite-bundle-ingest-store.js';
|
2026-05-10 23:12:26 +02:00
|
|
|
|
|
|
|
|
function idFactory(ids: string[]): () => string {
|
|
|
|
|
let index = 0;
|
|
|
|
|
return () => ids[index++] ?? `generated-${index}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runArgs(input: {
|
|
|
|
|
jobId: string;
|
|
|
|
|
syncId: string;
|
|
|
|
|
connectionId?: string;
|
|
|
|
|
sourceKey?: string;
|
|
|
|
|
trigger?: IngestTrigger;
|
|
|
|
|
}) {
|
|
|
|
|
return {
|
|
|
|
|
jobId: input.jobId,
|
|
|
|
|
connectionId: input.connectionId ?? 'docs',
|
|
|
|
|
sourceKey: input.sourceKey ?? 'notion',
|
|
|
|
|
syncId: input.syncId,
|
|
|
|
|
trigger: input.trigger ?? 'manual_resync',
|
|
|
|
|
scopeFingerprint: `scope-${input.syncId}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function diffSummary(overrides: Partial<IngestDiffSummary> = {}): IngestDiffSummary {
|
|
|
|
|
return {
|
|
|
|
|
added: 1,
|
|
|
|
|
modified: 0,
|
|
|
|
|
deleted: 0,
|
|
|
|
|
unchanged: 0,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reportBody(syncId: string, supersededBy: string | null = null): IngestReportBody {
|
|
|
|
|
return {
|
|
|
|
|
syncId,
|
|
|
|
|
diffSummary: diffSummary(),
|
|
|
|
|
commitSha: null,
|
|
|
|
|
workUnits: [
|
|
|
|
|
{
|
|
|
|
|
unitKey: 'revenue-policy',
|
|
|
|
|
rawFiles: ['pages/revenue.md'],
|
|
|
|
|
status: 'success',
|
|
|
|
|
actions: [],
|
|
|
|
|
touchedSlSources: [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
failedWorkUnits: [],
|
|
|
|
|
reconciliationSkipped: false,
|
|
|
|
|
conflictsResolved: [],
|
|
|
|
|
evictionsApplied: [],
|
|
|
|
|
unmappedFallbacks: [],
|
|
|
|
|
evictionInputs: [],
|
|
|
|
|
unresolvedCards: [],
|
|
|
|
|
supersededBy,
|
|
|
|
|
overrideOf: null,
|
|
|
|
|
provenanceRows: [],
|
|
|
|
|
toolTranscripts: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:56:58 -04:00
|
|
|
function emptyReportBody(syncId: string, overrides: Partial<IngestReportBody> = {}): IngestReportBody {
|
|
|
|
|
return {
|
|
|
|
|
syncId,
|
|
|
|
|
diffSummary: diffSummary({ added: 0, modified: 0, deleted: 0, unchanged: 1 }),
|
|
|
|
|
commitSha: null,
|
|
|
|
|
workUnits: [],
|
|
|
|
|
failedWorkUnits: [],
|
|
|
|
|
reconciliationSkipped: true,
|
|
|
|
|
conflictsResolved: [],
|
|
|
|
|
evictionsApplied: [],
|
|
|
|
|
unmappedFallbacks: [],
|
|
|
|
|
evictionInputs: [],
|
|
|
|
|
unresolvedCards: [],
|
|
|
|
|
supersededBy: null,
|
|
|
|
|
overrideOf: null,
|
|
|
|
|
provenanceRows: [],
|
|
|
|
|
toolTranscripts: [],
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
describe('SqliteBundleIngestStore', () => {
|
|
|
|
|
let tempDir: string;
|
|
|
|
|
let dbPath: string;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
2026-05-10 23:51:24 +02:00
|
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-bundle-ingest-store-'));
|
|
|
|
|
dbPath = join(tempDir, '.ktx', 'db.sqlite');
|
2026-05-10 23:12:26 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('persists run and report state across reopened SQLite handles', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({
|
|
|
|
|
dbPath,
|
|
|
|
|
idFactory: idFactory(['run-1', 'report-1']),
|
|
|
|
|
now: () => new Date('2026-04-30T10:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await store.create(runArgs({ jobId: 'job-1', syncId: 'sync-1' }));
|
|
|
|
|
expect(run).toEqual({ id: 'run-1' });
|
|
|
|
|
|
|
|
|
|
await store.markCompleted(run.id, diffSummary({ added: 2, unchanged: 1 }));
|
|
|
|
|
const report = await store.create({
|
|
|
|
|
runId: run.id,
|
|
|
|
|
jobId: 'job-1',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-1'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(report).toMatchObject({
|
|
|
|
|
id: 'report-1',
|
|
|
|
|
runId: 'run-1',
|
|
|
|
|
jobId: 'job-1',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: { syncId: 'sync-1' },
|
|
|
|
|
createdAt: '2026-04-30T10:00:00.000Z',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const reopened = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
await expect(reopened.findByJobId('job-1')).resolves.toMatchObject({
|
|
|
|
|
id: 'report-1',
|
|
|
|
|
runId: 'run-1',
|
|
|
|
|
body: { syncId: 'sync-1', supersededBy: null },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await reopened.markSuperseded('job-1', 'job-2');
|
|
|
|
|
await expect(reopened.findByJobId('job-1')).resolves.toMatchObject({
|
|
|
|
|
body: { syncId: 'sync-1', supersededBy: 'job-2' },
|
|
|
|
|
});
|
|
|
|
|
await expect(reopened.findByJobId('missing-job')).resolves.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses only completed runs when serving latest provenance hashes and artifacts', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({
|
|
|
|
|
dbPath,
|
|
|
|
|
idFactory: idFactory(['run-old', 'run-failed', 'run-new']),
|
|
|
|
|
now: () => new Date('2026-04-30T10:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const oldRun = await store.create(runArgs({ jobId: 'job-old', syncId: 'sync-old' }));
|
|
|
|
|
await store.insertMany([
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-old',
|
|
|
|
|
rawPath: 'pages/revenue.md',
|
|
|
|
|
rawContentHash: 'hash-old',
|
|
|
|
|
artifactKind: 'wiki',
|
2026-05-13 16:05:58 +02:00
|
|
|
artifactKey: 'wiki/global/revenue.md',
|
2026-05-10 23:12:26 +02:00
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'wiki_written',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await store.markCompleted(oldRun.id, diffSummary());
|
|
|
|
|
|
|
|
|
|
const failedRun = await store.create(runArgs({ jobId: 'job-failed', syncId: 'sync-failed' }));
|
|
|
|
|
await store.insertMany([
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-failed',
|
|
|
|
|
rawPath: 'pages/revenue.md',
|
|
|
|
|
rawContentHash: 'hash-failed',
|
|
|
|
|
artifactKind: null,
|
|
|
|
|
artifactKey: null,
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'skipped',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await store.markFailed(failedRun.id);
|
|
|
|
|
|
|
|
|
|
const newRun = await store.create(runArgs({ jobId: 'job-new', syncId: 'sync-new' }));
|
|
|
|
|
await store.insertMany([
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-new',
|
|
|
|
|
rawPath: 'pages/revenue.md',
|
|
|
|
|
rawContentHash: 'hash-new',
|
|
|
|
|
artifactKind: 'wiki',
|
2026-05-13 16:05:58 +02:00
|
|
|
artifactKey: 'wiki/global/revenue.md',
|
2026-05-10 23:12:26 +02:00
|
|
|
artifactContentHash: 'artifact-hash-new',
|
|
|
|
|
actionType: 'wiki_written',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-new',
|
|
|
|
|
rawPath: 'pages/revenue.md',
|
|
|
|
|
rawContentHash: 'hash-new',
|
|
|
|
|
artifactKind: 'sl',
|
|
|
|
|
artifactKey: 'warehouse.revenue',
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'measure_added',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await store.markCompleted(newRun.id, diffSummary({ modified: 1 }));
|
|
|
|
|
|
|
|
|
|
await expect(store.findLatestHashesForCompletedSyncs('docs', 'notion')).resolves.toEqual(
|
|
|
|
|
new Map([['pages/revenue.md', 'hash-new']]),
|
|
|
|
|
);
|
|
|
|
|
const diffSet = await new DiffSetService(store).compute(
|
|
|
|
|
'docs',
|
|
|
|
|
'notion',
|
|
|
|
|
new Map([
|
|
|
|
|
['pages/revenue.md', 'hash-new'],
|
|
|
|
|
['pages/new-policy.md', 'hash-added'],
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
expect(diffSet).toEqual({
|
|
|
|
|
added: ['pages/new-policy.md'],
|
|
|
|
|
modified: [],
|
|
|
|
|
deleted: [],
|
|
|
|
|
unchanged: ['pages/revenue.md'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const artifacts = await store.findLatestArtifactsForRawPaths('docs', 'notion', ['pages/revenue.md']);
|
|
|
|
|
expect(artifacts.get('pages/revenue.md')).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
sync_id: 'sync-new',
|
|
|
|
|
raw_content_hash: 'hash-new',
|
|
|
|
|
artifact_kind: 'wiki',
|
2026-05-13 16:05:58 +02:00
|
|
|
artifact_key: 'wiki/global/revenue.md',
|
2026-05-10 23:12:26 +02:00
|
|
|
action_type: 'wiki_written',
|
|
|
|
|
}),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
sync_id: 'sync-new',
|
|
|
|
|
artifact_kind: 'sl',
|
|
|
|
|
artifact_key: 'warehouse.revenue',
|
|
|
|
|
action_type: 'measure_added',
|
|
|
|
|
}),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 16:56:58 -04:00
|
|
|
it('does not baseline skipped provenance from failed work units or zero-work retry runs', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
const rawHashes = new Map([
|
|
|
|
|
['pages/page-1/metadata.json', 'hash-metadata'],
|
|
|
|
|
['pages/page-1/page.md', 'hash-page'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const failedRun = await store.create(runArgs({ jobId: 'job-failed-review', syncId: 'sync-failed-review' }));
|
|
|
|
|
await store.insertMany(
|
|
|
|
|
[...rawHashes].map(([rawPath, rawContentHash]) => ({
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-failed-review',
|
|
|
|
|
rawPath,
|
|
|
|
|
rawContentHash,
|
|
|
|
|
artifactKind: null,
|
|
|
|
|
artifactKey: null,
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'skipped' as const,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
await store.markCompleted(failedRun.id, diffSummary({ added: 2 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: failedRun.id,
|
|
|
|
|
jobId: 'job-failed-review',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: emptyReportBody('sync-failed-review', {
|
|
|
|
|
workUnits: [
|
|
|
|
|
{
|
|
|
|
|
unitKey: 'notion-page-page-1',
|
|
|
|
|
rawFiles: [...rawHashes.keys()],
|
|
|
|
|
status: 'failed',
|
|
|
|
|
reason: 'invalid_grant',
|
|
|
|
|
actions: [],
|
|
|
|
|
touchedSlSources: [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
failedWorkUnits: ['notion-page-page-1'],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const noWorkRun = await store.create(runArgs({ jobId: 'job-no-work', syncId: 'sync-no-work' }));
|
|
|
|
|
await store.insertMany(
|
|
|
|
|
[...rawHashes].map(([rawPath, rawContentHash]) => ({
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-no-work',
|
|
|
|
|
rawPath,
|
|
|
|
|
rawContentHash,
|
|
|
|
|
artifactKind: null,
|
|
|
|
|
artifactKey: null,
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'skipped' as const,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
await store.markCompleted(noWorkRun.id, diffSummary({ unchanged: 2 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: noWorkRun.id,
|
|
|
|
|
jobId: 'job-no-work',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: emptyReportBody('sync-no-work', { workUnits: [], failedWorkUnits: [] }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(store.findLatestHashesForCompletedSyncs('docs', 'notion')).resolves.toEqual(new Map());
|
|
|
|
|
await expect(new DiffSetService(store).compute('docs', 'notion', rawHashes)).resolves.toEqual({
|
|
|
|
|
added: ['pages/page-1/metadata.json', 'pages/page-1/page.md'],
|
|
|
|
|
modified: [],
|
|
|
|
|
deleted: [],
|
|
|
|
|
unchanged: [],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('baselines skipped provenance from successful no-output work unit runs', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
const run = await store.create(runArgs({ jobId: 'job-reviewed-no-output', syncId: 'sync-reviewed-no-output' }));
|
|
|
|
|
|
|
|
|
|
await store.insertMany([
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-reviewed-no-output',
|
|
|
|
|
rawPath: 'pages/page-1/page.md',
|
|
|
|
|
rawContentHash: 'hash-reviewed',
|
|
|
|
|
artifactKind: null,
|
|
|
|
|
artifactKey: null,
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'skipped',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await store.markCompleted(run.id, diffSummary({ added: 1 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: run.id,
|
|
|
|
|
jobId: 'job-reviewed-no-output',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: emptyReportBody('sync-reviewed-no-output', {
|
|
|
|
|
workUnits: [
|
|
|
|
|
{
|
|
|
|
|
unitKey: 'notion-page-page-1',
|
|
|
|
|
rawFiles: ['pages/page-1/page.md'],
|
|
|
|
|
status: 'success',
|
|
|
|
|
actions: [],
|
|
|
|
|
touchedSlSources: [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
failedWorkUnits: [],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(store.findLatestHashesForCompletedSyncs('docs', 'notion')).resolves.toEqual(
|
|
|
|
|
new Map([['pages/page-1/page.md', 'hash-reviewed']]),
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
new DiffSetService(store).compute('docs', 'notion', new Map([['pages/page-1/page.md', 'hash-reviewed']])),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
added: [],
|
|
|
|
|
unchanged: ['pages/page-1/page.md'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('baselines artifact provenance in partial failures but not skipped-only failed paths', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
const run = await store.create(runArgs({ jobId: 'job-partial', syncId: 'sync-partial' }));
|
|
|
|
|
|
|
|
|
|
await store.insertMany([
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-partial',
|
|
|
|
|
rawPath: 'pages/success/page.md',
|
|
|
|
|
rawContentHash: 'hash-success',
|
|
|
|
|
artifactKind: 'wiki',
|
2026-05-13 16:05:58 +02:00
|
|
|
artifactKey: 'wiki/notion/success.md',
|
2026-05-12 16:56:58 -04:00
|
|
|
artifactContentHash: 'artifact-success',
|
|
|
|
|
actionType: 'wiki_written',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
syncId: 'sync-partial',
|
|
|
|
|
rawPath: 'pages/failed/page.md',
|
|
|
|
|
rawContentHash: 'hash-failed',
|
|
|
|
|
artifactKind: null,
|
|
|
|
|
artifactKey: null,
|
|
|
|
|
artifactContentHash: null,
|
|
|
|
|
actionType: 'skipped',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await store.markCompleted(run.id, diffSummary({ added: 2 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: run.id,
|
|
|
|
|
jobId: 'job-partial',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: emptyReportBody('sync-partial', {
|
|
|
|
|
workUnits: [
|
|
|
|
|
{
|
|
|
|
|
unitKey: 'notion-page-success',
|
|
|
|
|
rawFiles: ['pages/success/page.md'],
|
|
|
|
|
status: 'success',
|
|
|
|
|
actions: [],
|
|
|
|
|
touchedSlSources: [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
unitKey: 'notion-page-failed',
|
|
|
|
|
rawFiles: ['pages/failed/page.md'],
|
|
|
|
|
status: 'failed',
|
|
|
|
|
reason: 'invalid_grant',
|
|
|
|
|
actions: [],
|
|
|
|
|
touchedSlSources: [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
failedWorkUnits: ['notion-page-failed'],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(store.findLatestHashesForCompletedSyncs('docs', 'notion')).resolves.toEqual(
|
|
|
|
|
new Map([['pages/success/page.md', 'hash-success']]),
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
new DiffSetService(store).compute(
|
|
|
|
|
'docs',
|
|
|
|
|
'notion',
|
|
|
|
|
new Map([
|
|
|
|
|
['pages/success/page.md', 'hash-success'],
|
|
|
|
|
['pages/failed/page.md', 'hash-failed'],
|
|
|
|
|
]),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toEqual({
|
|
|
|
|
added: ['pages/failed/page.md'],
|
|
|
|
|
modified: [],
|
|
|
|
|
deleted: [],
|
|
|
|
|
unchanged: ['pages/success/page.md'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
it('returns the latest stored report across bundle ingest runs', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({
|
|
|
|
|
dbPath,
|
|
|
|
|
idFactory: idFactory(['run-old', 'report-old', 'run-new', 'report-new']),
|
|
|
|
|
now: () => new Date('2026-04-30T10:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const oldRun = await store.create(runArgs({ jobId: 'job-old', syncId: 'sync-old' }));
|
|
|
|
|
await store.markCompleted(oldRun.id, diffSummary());
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: oldRun.id,
|
|
|
|
|
jobId: 'job-old',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-old'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const newRun = await store.create(runArgs({ jobId: 'job-new', syncId: 'sync-new' }));
|
|
|
|
|
await store.markCompleted(newRun.id, diffSummary({ modified: 1 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: newRun.id,
|
|
|
|
|
jobId: 'job-new',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-new'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(store.findLatestReport()).resolves.toMatchObject({
|
|
|
|
|
id: 'report-new',
|
|
|
|
|
runId: 'run-new',
|
|
|
|
|
jobId: 'job-new',
|
|
|
|
|
body: { syncId: 'sync-new' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('replaces a prior run with the same job_id when re-creating', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({
|
|
|
|
|
dbPath,
|
|
|
|
|
idFactory: idFactory(['run-old', 'report-old', 'run-new', 'report-new']),
|
|
|
|
|
now: () => new Date('2026-04-30T10:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const oldRun = await store.create(runArgs({ jobId: 'demo-full-ingest', syncId: 'sync-1' }));
|
|
|
|
|
expect(oldRun).toEqual({ id: 'run-old' });
|
|
|
|
|
await store.markCompleted(oldRun.id, diffSummary());
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: oldRun.id,
|
|
|
|
|
jobId: 'demo-full-ingest',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-1'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const newRun = await store.create(runArgs({ jobId: 'demo-full-ingest', syncId: 'sync-2' }));
|
|
|
|
|
expect(newRun).toEqual({ id: 'run-new' });
|
|
|
|
|
await store.markCompleted(newRun.id, diffSummary());
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: newRun.id,
|
|
|
|
|
jobId: 'demo-full-ingest',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-2'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const reopened = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
await expect(reopened.findByJobId('demo-full-ingest')).resolves.toMatchObject({
|
|
|
|
|
runId: 'run-new',
|
|
|
|
|
body: { syncId: 'sync-2' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lists local canonical pins for the bundle runner port', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({ dbPath });
|
|
|
|
|
|
|
|
|
|
await store.replaceCanonicalPins('docs', [
|
|
|
|
|
{
|
|
|
|
|
contestedKey: 'gross revenue',
|
|
|
|
|
canonicalArtifactKey: 'finance.revenue',
|
|
|
|
|
pinnedAt: '2026-04-30T09:00:00.000Z',
|
|
|
|
|
pinnedBy: 'analyst@example.com',
|
|
|
|
|
reason: 'Finance source is canonical.',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
contestedKey: 'active customer',
|
|
|
|
|
canonicalArtifactKey: 'crm.active_customer',
|
|
|
|
|
pinnedAt: '2026-04-30T09:05:00.000Z',
|
|
|
|
|
pinnedBy: 'analyst@example.com',
|
|
|
|
|
reason: null,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await expect(store.listPins(['docs'])).resolves.toEqual([
|
|
|
|
|
{
|
|
|
|
|
contestedKey: 'active customer',
|
|
|
|
|
canonicalArtifactKey: 'crm.active_customer',
|
|
|
|
|
pinnedAt: '2026-04-30T09:05:00.000Z',
|
|
|
|
|
pinnedBy: 'analyst@example.com',
|
|
|
|
|
reason: null,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
contestedKey: 'gross revenue',
|
|
|
|
|
canonicalArtifactKey: 'finance.revenue',
|
|
|
|
|
pinnedAt: '2026-04-30T09:00:00.000Z',
|
|
|
|
|
pinnedBy: 'analyst@example.com',
|
|
|
|
|
reason: 'Finance source is canonical.',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await expect(store.listPins(['other'])).resolves.toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('finds a report by report id, run id, or job id for local status and replay', async () => {
|
|
|
|
|
const store = new SqliteBundleIngestStore({
|
|
|
|
|
dbPath,
|
|
|
|
|
idFactory: idFactory(['run-lookup', 'report-lookup']),
|
|
|
|
|
now: () => new Date('2026-04-30T11:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await store.create(runArgs({ jobId: 'job-lookup', syncId: 'sync-lookup' }));
|
|
|
|
|
await store.markCompleted(run.id, diffSummary({ added: 1 }));
|
|
|
|
|
await store.create({
|
|
|
|
|
runId: run.id,
|
|
|
|
|
jobId: 'job-lookup',
|
|
|
|
|
connectionId: 'docs',
|
|
|
|
|
sourceKey: 'notion',
|
|
|
|
|
body: reportBody('sync-lookup'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(store.findReportByAnyId('report-lookup')).resolves.toMatchObject({
|
|
|
|
|
id: 'report-lookup',
|
|
|
|
|
runId: 'run-lookup',
|
|
|
|
|
jobId: 'job-lookup',
|
|
|
|
|
});
|
|
|
|
|
await expect(store.findReportByAnyId('run-lookup')).resolves.toMatchObject({
|
|
|
|
|
id: 'report-lookup',
|
|
|
|
|
runId: 'run-lookup',
|
|
|
|
|
jobId: 'job-lookup',
|
|
|
|
|
});
|
|
|
|
|
await expect(store.findReportByAnyId('job-lookup')).resolves.toMatchObject({
|
|
|
|
|
id: 'report-lookup',
|
|
|
|
|
runId: 'run-lookup',
|
|
|
|
|
jobId: 'job-lookup',
|
|
|
|
|
});
|
|
|
|
|
await expect(store.findReportByAnyId('missing')).resolves.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|