feat(linkedin-studio): RE-R2a — item→store capture bridge + publishedAt persistence (schema v1→v2, lossless migrate) [skip-docs]

Closes the research-engine capture loop RE-R1 deferred:
- itemToInput(item, capturedAt): pure envelope→TrendInput bridge in item.ts —
  injects capturedAt, carries publishedAt verbatim; no id, no re-validate
- publishedAt persisted: TrendRecord/TrendInput gain it; addTrend conditional-spread,
  first-sight kept on re-capture (no back-fill). SCHEMA_VERSION 1→2 with a lossless
  forward migrate-on-load: Math.max(onDisk, current) + numeric-typeof coercion
  (string/NaN/absent → current; non-array trends coercion preserved verbatim)
- `capture` CLI: stdin raw item|batch → normalize → bridge → addTrend → saveStore once;
  tally {added,duplicates,merged,errors} from AddResult; content-invalid → errors[],
  exit 2 only on bad stdin; --json summary
- wiring: trend-spotter.md Step 4.5 N×`add` → one normalizing `capture` batch; README
  add/capture framing corrected; test-runner Section 16h (capture wiring, unconditional)
  + floors bumped (trends 62→79, ASSERT 87→90)

TDD: 17 new tests (12 genuinely-RED logic-RED + 5 regression guards), tsc clean,
gate 105/0/0. No version bump (additive, v0.5.2 dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 11:12:50 +02:00
commit 7a158030b6
10 changed files with 465 additions and 31 deletions

View file

@ -2,6 +2,9 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
// `tsx` loader resolve regardless of the runner's cwd.
@ -67,4 +70,92 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
assert.equal(status, 2);
});
});
describe("capture (stdin raw item|batch -> folds into the store) (RE-R2a / Step 4)", () => {
const tmpStore = () => join(mkdtempSync(join(tmpdir(), "trends-capture-")), "trends.json");
test("happy path: a valid item piped in -> folded into the store, added:1, publishedAt persisted", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Captured",
url: "https://example.com/c",
topics: ["ai"],
publishedAt: "2026-06-20",
},
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 0);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
1,
"tally must sum to the input size",
);
const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.schemaVersion, 2);
assert.equal(persisted.trends.length, 1);
assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
assert.notEqual(
persisted.trends[0].capturedAt,
persisted.trends[0].publishedAt,
"capturedAt (when WE saw it) must be distinct from publishedAt (source date)",
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("a batch with one content-invalid item -> valid added, invalid in errors[], exit 0", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{ source: "tavily", title: "Valid", url: "https://example.com/v", topics: ["x"] },
{ title: "no source or url" },
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 1);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
2,
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("re-capturing the same trend with a new topic -> merged:1, tally still sums", () => {
const store = tmpStore();
try {
const item = (topics: string[]) =>
JSON.stringify([{ source: "tavily", title: "Dup", url: "https://example.com/d", topics }]);
run(["capture", "--store", store, "--json"], item(["a"]));
const { status, stdout } = run(["capture", "--store", store, "--json"], item(["a", "b"]));
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 0);
assert.equal(summary.merged, 1);
assert.equal(summary.duplicates, 0);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
1,
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("bad invocation: empty stdin -> exit 2", () => {
const { status } = run(["capture"], "");
assert.equal(status, 2);
});
});
});