social-autoposter 1.6.13 → 1.6.14
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/bin/cli.js +125 -13
- package/bin/server.js +321 -2
- package/package.json +1 -1
- package/schema-postgres.sql +51 -0
- package/scripts/log_post.py +11 -0
- package/scripts/realign_sequences.py +60 -0
- package/scripts/score_linkedin_candidates.py +7 -1
- package/scripts/scratch_seo_gsc.py +134 -0
- package/scripts/scratch_seo_posthog.py +179 -0
- package/scripts/scratch_seo_volume.py +136 -0
- package/scripts/twitter_browser.py +47 -3
- package/scripts/twitter_post_plan.py +86 -0
- package/skill/link-edit-github.sh +26 -6
- package/skill/link-edit-linkedin.sh +26 -6
- package/skill/run-twitter-cycle.sh +21 -5
package/bin/cli.js
CHANGED
|
@@ -153,20 +153,20 @@ function isAppMakerVm() {
|
|
|
153
153
|
// AppMaker-specific TWITTER_CDP_URL before its `${VAR:-default}` fallback hits.
|
|
154
154
|
// Idempotent: rewrites the file every invocation so a config edit on the VM
|
|
155
155
|
// can't drift away from what cli.js intends.
|
|
156
|
-
function writeAppMakerEnvFile() {
|
|
156
|
+
function writeAppMakerEnvFile(handleFromDb) {
|
|
157
157
|
const envPath = path.join(HOME, '.social-autoposter-env');
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
158
|
+
// Source of truth for the handle is the DB (social_accounts.handle keyed by
|
|
159
|
+
// vm_session_key). bootstrap-vm passes it in. Fallback: preserve a previously
|
|
160
|
+
// set value across rewrites if no DB-sourced handle was provided (matters
|
|
161
|
+
// when this runs from `social-autoposter update` without a fresh DB fetch).
|
|
162
|
+
let preservedHandle = String(handleFromDb || '').trim().replace(/^@/, '');
|
|
163
|
+
if (!preservedHandle) {
|
|
164
|
+
try {
|
|
165
|
+
const prev = fs.readFileSync(envPath, 'utf8');
|
|
166
|
+
const m = prev.match(/^\s*export\s+AUTOPOSTER_TWITTER_HANDLE=(.+)\s*$/m);
|
|
167
|
+
if (m) preservedHandle = m[1].trim();
|
|
168
|
+
} catch { /* no prior file */ }
|
|
169
|
+
}
|
|
170
170
|
|
|
171
171
|
const lines = [
|
|
172
172
|
'# social-autoposter per-host env overrides',
|
|
@@ -290,6 +290,115 @@ function applyAppMakerMcpConfigOverrides() {
|
|
|
290
290
|
// uv installed and broke Phase 1's Claude scan (the MCP server's `command:
|
|
291
291
|
// /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
|
|
292
292
|
// empty envelope).
|
|
293
|
+
// AppMaker VM self-bootstrap. Single entry point that the appmaker template
|
|
294
|
+
// startup.sh calls on every fresh sandbox boot. Reads the stable sessionKey
|
|
295
|
+
// from /run/mk0r-session.json (which the appmaker bridge rewrites on every
|
|
296
|
+
// session bind, and which survives E2B sandbox substitution — only the
|
|
297
|
+
// sandboxId changes), then asks the social-autoposter HTTP API which Twitter
|
|
298
|
+
// account this VM is bound to (handle + stored login cookies, keyed by
|
|
299
|
+
// social_accounts.vm_session_key). With that single DB answer it sets up
|
|
300
|
+
// everything: env file (with the DB-sourced handle), profile symlink, MCP
|
|
301
|
+
// config (BH_PORT=9222), uuid-runtime, then restores the Twitter login by
|
|
302
|
+
// re-injecting the stored cookies via CDP.
|
|
303
|
+
//
|
|
304
|
+
// This is the "one proper fix" for sandbox substitution: the VM holds no
|
|
305
|
+
// per-VM state on disk — the DB does, keyed by the stable sessionKey. So
|
|
306
|
+
// any fresh sandbox can rebuild itself by reading /run/mk0r-session.json
|
|
307
|
+
// and calling one route.
|
|
308
|
+
function bootstrapVm() {
|
|
309
|
+
if (!isAppMakerVm()) {
|
|
310
|
+
console.error('bootstrap-vm: not an AppMaker VM (no /opt/startup.sh + CDP :9222). Use `init` or `update` on dev boxes.');
|
|
311
|
+
process.exit(2);
|
|
312
|
+
}
|
|
313
|
+
console.log(' AppMaker VM bootstrap: resolving identity from DB by sessionKey...');
|
|
314
|
+
|
|
315
|
+
let sessionKey;
|
|
316
|
+
try {
|
|
317
|
+
const raw = fs.readFileSync('/run/mk0r-session.json', 'utf8');
|
|
318
|
+
sessionKey = (JSON.parse(raw) || {}).sessionKey;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error(`bootstrap-vm: cannot read /run/mk0r-session.json: ${err.message}`);
|
|
321
|
+
process.exit(3);
|
|
322
|
+
}
|
|
323
|
+
if (!sessionKey) {
|
|
324
|
+
console.error('bootstrap-vm: /run/mk0r-session.json has no sessionKey');
|
|
325
|
+
process.exit(3);
|
|
326
|
+
}
|
|
327
|
+
console.log(` sessionKey=${sessionKey}`);
|
|
328
|
+
|
|
329
|
+
// Get the X-Installation header via identity.py (same Python helper http_api.py
|
|
330
|
+
// uses, so auth stays single-sourced).
|
|
331
|
+
const identityPath = path.join(PKG_ROOT, 'scripts', 'identity.py');
|
|
332
|
+
const headerRes = spawnSync('/usr/bin/python3', [identityPath, 'header'], {
|
|
333
|
+
encoding: 'utf8',
|
|
334
|
+
});
|
|
335
|
+
if (headerRes.status !== 0) {
|
|
336
|
+
console.error(`bootstrap-vm: identity.py header failed: ${headerRes.stderr || headerRes.error}`);
|
|
337
|
+
process.exit(4);
|
|
338
|
+
}
|
|
339
|
+
const installHeader = (headerRes.stdout || '').trim();
|
|
340
|
+
|
|
341
|
+
const base = (process.env.AUTOPOSTER_API_BASE || 'https://s4l.ai').replace(/\/+$/, '');
|
|
342
|
+
const url = `${base}/api/v1/twitter/vm-session?session_key=${encodeURIComponent(sessionKey)}`;
|
|
343
|
+
console.log(` GET ${url}`);
|
|
344
|
+
|
|
345
|
+
// Use curl (always present on the appmaker template) so we don't pull in
|
|
346
|
+
// a Node HTTP dep here.
|
|
347
|
+
const curl = spawnSync('curl', [
|
|
348
|
+
'-sS', '--max-time', '15',
|
|
349
|
+
'-H', `X-Installation: ${installHeader}`,
|
|
350
|
+
'-H', 'Content-Type: application/json',
|
|
351
|
+
url,
|
|
352
|
+
], { encoding: 'utf8' });
|
|
353
|
+
if (curl.status !== 0) {
|
|
354
|
+
console.error(`bootstrap-vm: curl failed: ${curl.stderr || curl.error}`);
|
|
355
|
+
process.exit(5);
|
|
356
|
+
}
|
|
357
|
+
let payload;
|
|
358
|
+
try {
|
|
359
|
+
payload = JSON.parse(curl.stdout || '{}');
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error(`bootstrap-vm: bad JSON from API: ${curl.stdout.slice(0, 300)}`);
|
|
362
|
+
process.exit(6);
|
|
363
|
+
}
|
|
364
|
+
if (!payload.ok || !payload.data) {
|
|
365
|
+
console.error(`bootstrap-vm: API error: ${JSON.stringify(payload).slice(0, 300)}`);
|
|
366
|
+
process.exit(7);
|
|
367
|
+
}
|
|
368
|
+
const { handle, cookies, vm_project_id } = payload.data;
|
|
369
|
+
if (!handle) {
|
|
370
|
+
console.error('bootstrap-vm: API returned no handle. social_accounts.vm_session_key may be unset for this VM.');
|
|
371
|
+
process.exit(8);
|
|
372
|
+
}
|
|
373
|
+
console.log(` bound to @${handle} (vm_project_id=${vm_project_id || 'none'}, cookies=${(cookies || []).length})`);
|
|
374
|
+
|
|
375
|
+
// Write env file with DB-sourced handle (durable across `social-autoposter update`).
|
|
376
|
+
writeAppMakerEnvFile(handle);
|
|
377
|
+
|
|
378
|
+
// Existing setup steps. installBrowserHarness already installs uuid-runtime,
|
|
379
|
+
// symlinks the profile, and patches the MCP config — call it directly.
|
|
380
|
+
installBrowserHarness();
|
|
381
|
+
|
|
382
|
+
// Restore the Twitter login if we have stored cookies and the Chrome is
|
|
383
|
+
// up. No-op when Chrome isn't reachable yet (startup ordering); the cycle
|
|
384
|
+
// preflight will run restore_twitter_session.py on its next tick.
|
|
385
|
+
if ((cookies || []).length > 0) {
|
|
386
|
+
const restorePath = path.join(HOME, 'social-autoposter', 'scripts', 'restore_twitter_session.py');
|
|
387
|
+
if (fs.existsSync(restorePath)) {
|
|
388
|
+
console.log(' invoking restore_twitter_session.py to re-inject cookies...');
|
|
389
|
+
// Source the env file so AUTOPOSTER_TWITTER_HANDLE / TWITTER_CDP_URL are set.
|
|
390
|
+
const r = spawnSync('bash', ['-lc',
|
|
391
|
+
`source ${HOME}/.social-autoposter-env 2>/dev/null; /usr/bin/python3 ${restorePath} || true`,
|
|
392
|
+
], { stdio: 'inherit' });
|
|
393
|
+
void r;
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
console.log(' no stored cookies; manual login still required this once.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log(' bootstrap-vm: done.');
|
|
400
|
+
}
|
|
401
|
+
|
|
293
402
|
function installBrowserHarness() {
|
|
294
403
|
const onAppMaker = isAppMakerVm();
|
|
295
404
|
if (onAppMaker) {
|
|
@@ -745,6 +854,8 @@ if (cmd === 'init') {
|
|
|
745
854
|
init();
|
|
746
855
|
} else if (cmd === 'update') {
|
|
747
856
|
update();
|
|
857
|
+
} else if (cmd === 'bootstrap-vm') {
|
|
858
|
+
bootstrapVm();
|
|
748
859
|
} else if (cmd === 'export-cookies') {
|
|
749
860
|
// Forward to cookie-helper with 'export' + remaining args
|
|
750
861
|
process.argv = [process.argv[0], process.argv[1], 'export', ...process.argv.slice(3)];
|
|
@@ -762,6 +873,7 @@ if (cmd === 'init') {
|
|
|
762
873
|
console.log(' npx social-autoposter open the dashboard');
|
|
763
874
|
console.log(' npx social-autoposter init first-time setup');
|
|
764
875
|
console.log(' npx social-autoposter update update scripts, preserve config');
|
|
876
|
+
console.log(' npx social-autoposter bootstrap-vm AppMaker VM self-bootstrap (DB-driven)');
|
|
765
877
|
console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
|
|
766
878
|
console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
|
|
767
879
|
}
|
package/bin/server.js
CHANGED
|
@@ -572,6 +572,17 @@ const PLATFORM_LABELS = {
|
|
|
572
572
|
hackernews: 'HackerNews', youtube: 'YouTube', instagram: 'Instagram',
|
|
573
573
|
};
|
|
574
574
|
|
|
575
|
+
// twitter-ripen-freshness-abc variant defs (shipped 2026-05-22). Shared between
|
|
576
|
+
// the /api/experiments endpoint (Experiments tab) and the post-comments-twitter
|
|
577
|
+
// row in the job-history pill render so both surfaces describe a variant the
|
|
578
|
+
// same way. When the experiment ends, drop the desc but keep the keys so old
|
|
579
|
+
// history rows still resolve to something readable.
|
|
580
|
+
const TWITTER_VARIANT_DEFS = {
|
|
581
|
+
A: { label: 'Control', desc: 'ripen + 6h freshness (legacy)' },
|
|
582
|
+
B: { label: 'No-ripen', desc: 'skip 20-min wait + 6h freshness' },
|
|
583
|
+
C: { label: 'No-ripen + tight', desc: 'skip 20-min wait + 1h freshness' },
|
|
584
|
+
};
|
|
585
|
+
|
|
575
586
|
// Standalone jobs with no platform axis. script_name -> display label.
|
|
576
587
|
const STANDALONE_JOBS = {
|
|
577
588
|
serp_seo: { job_type: 'seo', job_label: 'SERP SEO' },
|
|
@@ -1307,7 +1318,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1307
1318
|
const candidateRows = await pq(
|
|
1308
1319
|
"SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
|
|
1309
1320
|
" (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
|
|
1310
|
-
" matched_project, tweet_url " +
|
|
1321
|
+
" matched_project, tweet_url, cycle_variant " +
|
|
1311
1322
|
"FROM twitter_candidates " +
|
|
1312
1323
|
"WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
|
|
1313
1324
|
[since]
|
|
@@ -1384,9 +1395,34 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1384
1395
|
batch_id: r.batch_id || '',
|
|
1385
1396
|
matched_project: r.matched_project || '',
|
|
1386
1397
|
tweet_url: r.tweet_url || '',
|
|
1398
|
+
cycle_variant: r.cycle_variant || null,
|
|
1387
1399
|
};
|
|
1388
1400
|
});
|
|
1389
1401
|
|
|
1402
|
+
// Deterministic variant fallback: when a run has an ownBatchId but no
|
|
1403
|
+
// candidate rows yet carry cycle_variant (e.g. Phase 1 aborted before
|
|
1404
|
+
// upserting any row, or rows were rolled back), recompute it the same way
|
|
1405
|
+
// skill/run-twitter-cycle.sh does: sha1(batch_id) % 3 -> 'ABC'. Keeps the
|
|
1406
|
+
// job-history "experiment X" pill populated for scan-only / aborted cycles.
|
|
1407
|
+
// Ship date of twitter-ripen-freshness-abc. Before this, run-twitter-cycle.sh
|
|
1408
|
+
// didn't compute TWITTER_CYCLE_VARIANT and every cycle ran legacy (=variant A
|
|
1409
|
+
// by today's labels). We refuse to back-label those runs from the hash because
|
|
1410
|
+
// it would mislabel them as B/C — they never actually ran those code paths.
|
|
1411
|
+
const EXPERIMENT_SHIP_MS = new Date('2026-05-22T00:00:00').getTime();
|
|
1412
|
+
const variantFromBatchId = (batchId) => {
|
|
1413
|
+
if (!batchId) return null;
|
|
1414
|
+
const batchMs = parseTwitterBatchIdMs(batchId);
|
|
1415
|
+
if (!Number.isFinite(batchMs) || batchMs < EXPERIMENT_SHIP_MS) return null;
|
|
1416
|
+
try {
|
|
1417
|
+
const hex = crypto.createHash('sha1').update(batchId).digest('hex');
|
|
1418
|
+
// Mirror Python's int(hex,16) % 3 exactly. The 160-bit hash doesn't fit
|
|
1419
|
+
// in a JS Number, so use BigInt for the full-width modulus; truncating
|
|
1420
|
+
// hex digits would silently disagree with the shell-side variant.
|
|
1421
|
+
const n = BigInt('0x' + hex);
|
|
1422
|
+
return 'ABC'[Number(n % 3n)];
|
|
1423
|
+
} catch { return null; }
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1390
1426
|
for (const run of txRuns) {
|
|
1391
1427
|
const startMs = new Date(run.started_at).getTime();
|
|
1392
1428
|
const endMs = new Date(run.finished_at).getTime() + 60 * 1000;
|
|
@@ -1440,10 +1476,16 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1440
1476
|
projectsList.push(proj);
|
|
1441
1477
|
}
|
|
1442
1478
|
};
|
|
1479
|
+
// Experiment variant (A/B/C) for this run. Primary source: any candidate
|
|
1480
|
+
// row tied to ownBatchId carries cycle_variant (Phase 1 stamps it after
|
|
1481
|
+
// discovery). Fallback: recompute from sha1(ownBatchId) % 3 so scan-only
|
|
1482
|
+
// / aborted cycles still surface the right bucket.
|
|
1483
|
+
let runVariant = null;
|
|
1443
1484
|
for (const c of candNorm) {
|
|
1444
1485
|
if (!ownBatchId || c.batch_id !== ownBatchId) continue;
|
|
1445
1486
|
candidatesPassed++;
|
|
1446
1487
|
recordProject(c.matched_project);
|
|
1488
|
+
if (!runVariant && c.cycle_variant) runVariant = c.cycle_variant;
|
|
1447
1489
|
if (c.status === 'posted') {
|
|
1448
1490
|
posted++;
|
|
1449
1491
|
// Salvage signature: candidate's discovered_at predates this cycle's
|
|
@@ -1622,6 +1664,12 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1622
1664
|
// pill row so the operator can see at a glance which projects consumed
|
|
1623
1665
|
// the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
|
|
1624
1666
|
projects_worked: projectsList,
|
|
1667
|
+
// A/B/C experiment bucket for this run (twitter-ripen-freshness-abc,
|
|
1668
|
+
// shipped 2026-05-22). Primary source is twitter_candidates.cycle_variant
|
|
1669
|
+
// for any row tied to ownBatchId; falls back to sha1(ownBatchId) % 3 so
|
|
1670
|
+
// scan-only / aborted cycles still surface the variant. Rendered next to
|
|
1671
|
+
// `projects` in the job-history result row.
|
|
1672
|
+
experiment_variant: runVariant || variantFromBatchId(ownBatchId),
|
|
1625
1673
|
styles_used: stylesUsedTx,
|
|
1626
1674
|
cost_usd: prior.cost_usd || 0,
|
|
1627
1675
|
failed: prior.failed || 0,
|
|
@@ -5331,6 +5379,109 @@ async function handleApi(req, res) {
|
|
|
5331
5379
|
return;
|
|
5332
5380
|
}
|
|
5333
5381
|
|
|
5382
|
+
// GET /api/experiments - currently-running A/B/C tests across the pipeline.
|
|
5383
|
+
// First (and only, for now) experiment is the twitter-cycle variant test
|
|
5384
|
+
// shipped 2026-05-22: variant A = ripen+6h (control), B = no-ripen+6h, C =
|
|
5385
|
+
// no-ripen+1h. Variant assignment lives on twitter_candidates.cycle_variant
|
|
5386
|
+
// (set by skill/run-twitter-cycle.sh from hash(BATCH_ID) % 3). This endpoint
|
|
5387
|
+
// reads that column directly; no separate experiments table yet. When a
|
|
5388
|
+
// second experiment ships we'll either add another block here or promote
|
|
5389
|
+
// to a real table. Admin-only: experimentation state isn't client-facing.
|
|
5390
|
+
if (p === '/api/experiments' && req.method === 'GET') {
|
|
5391
|
+
if (!req.user.admin) return json(res, { error: 'forbidden' }, 403);
|
|
5392
|
+
(async () => {
|
|
5393
|
+
try {
|
|
5394
|
+
// One row per variant, joining candidates -> posts to derive the
|
|
5395
|
+
// post-rate funnel and engagement-on-our-reply. Thread-age-at-discover
|
|
5396
|
+
// p50 uses tweet_posted_at vs discovered_at (we have ~100% coverage on
|
|
5397
|
+
// tweet_posted_at over the experiment window). Engagement is taken from
|
|
5398
|
+
// twitter_candidates.{likes,replies,views} which the stats job refreshes
|
|
5399
|
+
// to T-final values, not the t0/t1 snapshot columns.
|
|
5400
|
+
const rows = await pq(`
|
|
5401
|
+
WITH base AS (
|
|
5402
|
+
SELECT tc.cycle_variant AS variant,
|
|
5403
|
+
tc.id,
|
|
5404
|
+
tc.batch_id,
|
|
5405
|
+
tc.status,
|
|
5406
|
+
tc.discovered_at,
|
|
5407
|
+
tc.tweet_posted_at,
|
|
5408
|
+
tc.posted_at,
|
|
5409
|
+
tc.post_id,
|
|
5410
|
+
EXTRACT(EPOCH FROM (tc.discovered_at - tc.tweet_posted_at)) / 60.0 AS thread_age_min,
|
|
5411
|
+
p.views AS our_views,
|
|
5412
|
+
p.upvotes AS our_likes,
|
|
5413
|
+
p.comments_count AS our_replies
|
|
5414
|
+
FROM twitter_candidates tc
|
|
5415
|
+
LEFT JOIN posts p ON p.id = tc.post_id
|
|
5416
|
+
WHERE tc.cycle_variant IS NOT NULL
|
|
5417
|
+
AND tc.cycle_variant IN ('A','B','C')
|
|
5418
|
+
AND tc.discovered_at > NOW() - INTERVAL '30 days'
|
|
5419
|
+
)
|
|
5420
|
+
SELECT variant,
|
|
5421
|
+
COUNT(*) AS n_candidates,
|
|
5422
|
+
COUNT(DISTINCT batch_id) AS n_batches,
|
|
5423
|
+
COUNT(*) FILTER (WHERE status = 'posted') AS n_posted,
|
|
5424
|
+
COUNT(*) FILTER (WHERE status = 'skipped') AS n_skipped,
|
|
5425
|
+
COUNT(*) FILTER (WHERE status = 'expired') AS n_expired,
|
|
5426
|
+
COUNT(*) FILTER (WHERE status = 'pending') AS n_pending,
|
|
5427
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY thread_age_min)
|
|
5428
|
+
FILTER (WHERE thread_age_min IS NOT NULL) AS thread_age_min_p50,
|
|
5429
|
+
AVG(our_views) FILTER (WHERE status = 'posted') AS avg_views,
|
|
5430
|
+
AVG(our_likes) FILTER (WHERE status = 'posted') AS avg_likes,
|
|
5431
|
+
AVG(our_replies) FILTER (WHERE status = 'posted') AS avg_replies,
|
|
5432
|
+
MIN(discovered_at) AS started_at
|
|
5433
|
+
FROM base
|
|
5434
|
+
GROUP BY variant
|
|
5435
|
+
ORDER BY variant
|
|
5436
|
+
`, []);
|
|
5437
|
+
const variantDefs = TWITTER_VARIANT_DEFS;
|
|
5438
|
+
const variants = ['A', 'B', 'C'].map(k => {
|
|
5439
|
+
const row = (rows || []).find(r => r.variant === k) || {};
|
|
5440
|
+
const n = Number(row.n_candidates || 0);
|
|
5441
|
+
const posted = Number(row.n_posted || 0);
|
|
5442
|
+
return {
|
|
5443
|
+
key: k,
|
|
5444
|
+
label: variantDefs[k].label,
|
|
5445
|
+
desc: variantDefs[k].desc,
|
|
5446
|
+
n_candidates: n,
|
|
5447
|
+
n_batches: Number(row.n_batches || 0),
|
|
5448
|
+
n_posted: posted,
|
|
5449
|
+
n_skipped: Number(row.n_skipped || 0),
|
|
5450
|
+
n_expired: Number(row.n_expired || 0),
|
|
5451
|
+
n_pending: Number(row.n_pending || 0),
|
|
5452
|
+
post_rate_pct: n > 0 ? (posted / n) * 100 : null,
|
|
5453
|
+
thread_age_min_p50: row.thread_age_min_p50 != null ? Number(row.thread_age_min_p50) : null,
|
|
5454
|
+
avg_views: row.avg_views != null ? Number(row.avg_views) : null,
|
|
5455
|
+
avg_likes: row.avg_likes != null ? Number(row.avg_likes) : null,
|
|
5456
|
+
avg_replies: row.avg_replies != null ? Number(row.avg_replies) : null,
|
|
5457
|
+
started_at: row.started_at || null,
|
|
5458
|
+
};
|
|
5459
|
+
});
|
|
5460
|
+
const totals = variants.reduce((acc, v) => {
|
|
5461
|
+
acc.n_candidates += v.n_candidates;
|
|
5462
|
+
acc.n_posted += v.n_posted;
|
|
5463
|
+
acc.n_batches += v.n_batches;
|
|
5464
|
+
return acc;
|
|
5465
|
+
}, { n_candidates: 0, n_posted: 0, n_batches: 0 });
|
|
5466
|
+
const startedAt = variants.map(v => v.started_at).filter(Boolean).sort()[0] || null;
|
|
5467
|
+
const experiments = [{
|
|
5468
|
+
id: 'twitter-ripen-freshness-abc',
|
|
5469
|
+
name: 'Twitter ripen + freshness (A/B/C)',
|
|
5470
|
+
status: 'running',
|
|
5471
|
+
started_at: startedAt,
|
|
5472
|
+
hypothesis: 'Skipping the 20-min ripen and tightening discover freshness to 1h cuts thread-age-at-discover (p50 ~181 min on legacy) without hurting post-rate.',
|
|
5473
|
+
primary_metric: 'thread_age_min_p50',
|
|
5474
|
+
totals,
|
|
5475
|
+
variants,
|
|
5476
|
+
}];
|
|
5477
|
+
json(res, { experiments });
|
|
5478
|
+
} catch (e) {
|
|
5479
|
+
json(res, { error: String(e && e.message || e) }, 500);
|
|
5480
|
+
}
|
|
5481
|
+
})();
|
|
5482
|
+
return;
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5334
5485
|
// GET /api/cost/stats - per-activity-type count + total cost over a trailing
|
|
5335
5486
|
// window. Types: thread (posts.posted_at), comment (replies.replied),
|
|
5336
5487
|
// page (seo_keywords + gsc_queries), dm_thread (dms.sent_at). Per-row cost is
|
|
@@ -8049,6 +8200,33 @@ const HTML = `<!DOCTYPE html>
|
|
|
8049
8200
|
.style-stats-table tfoot td { border-top: 2px solid var(--border-strong, var(--border)); border-bottom: none; background: var(--bg-subtle); font-weight: 600; color: var(--text); }
|
|
8050
8201
|
.style-stats-table tfoot td:first-child { text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; color: var(--text-secondary); }
|
|
8051
8202
|
.style-stats-empty { padding: 16px 20px; color: var(--text-muted); font-size: 13px; border-top: 1px solid var(--border); }
|
|
8203
|
+
|
|
8204
|
+
/* Experiments tab: A/B/C cards.
|
|
8205
|
+
Reuses base card chrome from .style-stats-table so it inherits the dashboard theme. */
|
|
8206
|
+
.experiments-header { padding: 20px 20px 8px; }
|
|
8207
|
+
.experiments-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--text); }
|
|
8208
|
+
.experiments-sub { margin-top: 4px; font-size: 12px; color: var(--text-muted); }
|
|
8209
|
+
.exp-kpi-band { display: flex; gap: 24px; padding: 12px 20px 20px; border-bottom: 1px solid var(--border); }
|
|
8210
|
+
.exp-kpi { display: flex; flex-direction: column; gap: 2px; }
|
|
8211
|
+
.exp-kpi-n { font-size: 22px; font-weight: 700; color: var(--text); line-height: 1; }
|
|
8212
|
+
.exp-kpi-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
8213
|
+
.exp-card { margin: 20px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-subtle); overflow: hidden; }
|
|
8214
|
+
.exp-card-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; padding: 16px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
8215
|
+
.exp-card-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
8216
|
+
.exp-card-hypothesis { margin-top: 4px; font-size: 12px; color: var(--text-secondary); max-width: 720px; line-height: 1.45; }
|
|
8217
|
+
.exp-card-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; font-size: 11px; color: var(--text-muted); }
|
|
8218
|
+
.exp-status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
8219
|
+
.exp-status-running { background: var(--accent-panel-bg); color: var(--text); border: 1px solid #3b82f6; }
|
|
8220
|
+
.exp-status-decided { background: #6b21a8; color: #fff; }
|
|
8221
|
+
.exp-status-shipped { background: #166534; color: #fff; }
|
|
8222
|
+
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
8223
|
+
.exp-table th { text-align: left; padding: 10px 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; font-weight: 600; border-bottom: 1px solid var(--border); background: var(--bg); }
|
|
8224
|
+
.exp-table th.num, .exp-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
8225
|
+
.exp-table td { padding: 12px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; }
|
|
8226
|
+
.exp-table tbody tr:last-child td { border-bottom: none; }
|
|
8227
|
+
.exp-variant-key { display: inline-block; min-width: 22px; text-align: center; padding: 1px 6px; border-radius: 4px; background: var(--bg); border: 1px solid var(--border); font-weight: 700; margin-right: 6px; }
|
|
8228
|
+
.exp-variant-label { font-weight: 600; }
|
|
8229
|
+
.exp-variant-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
8052
8230
|
.style-stats-controls { padding: 10px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
|
|
8053
8231
|
.style-stats-pill-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
|
8054
8232
|
.style-stats-pill-row .label { color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; margin-right: 4px; }
|
|
@@ -8180,6 +8358,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
8180
8358
|
<div class="tab" data-tab="trends">Trends</div>
|
|
8181
8359
|
<div class="tab" data-tab="activity">Activity</div>
|
|
8182
8360
|
<div class="tab" data-tab="top">Top</div>
|
|
8361
|
+
<div class="tab sa-admin-only" data-tab="experiments">Experiments</div>
|
|
8183
8362
|
<div class="tab sa-admin-only" data-tab="logs">Logs</div>
|
|
8184
8363
|
<div class="tab sa-admin-only" data-tab="settings">Settings</div>
|
|
8185
8364
|
</div>
|
|
@@ -8649,6 +8828,16 @@ const HTML = `<!DOCTYPE html>
|
|
|
8649
8828
|
</div>
|
|
8650
8829
|
</div>
|
|
8651
8830
|
|
|
8831
|
+
<div class="content hidden" id="tab-experiments">
|
|
8832
|
+
<div class="experiments-header">
|
|
8833
|
+
<h2 class="experiments-title">Experiments</h2>
|
|
8834
|
+
<div class="experiments-sub">A/B/C tests running across the pipeline. Read-only first cut; one card per active experiment, variants side-by-side.</div>
|
|
8835
|
+
</div>
|
|
8836
|
+
<div id="experiments-list">
|
|
8837
|
+
<div class="style-stats-empty">Loading\u2026</div>
|
|
8838
|
+
</div>
|
|
8839
|
+
</div>
|
|
8840
|
+
|
|
8652
8841
|
<div class="content hidden" id="tab-logs">
|
|
8653
8842
|
<div class="log-controls">
|
|
8654
8843
|
<select id="log-job-filter">
|
|
@@ -9165,6 +9354,13 @@ function fmtElapsed(s) {
|
|
|
9165
9354
|
return h + 'h ' + (m % 60) + 'm';
|
|
9166
9355
|
}
|
|
9167
9356
|
|
|
9357
|
+
// twitter-ripen-freshness-abc variant defs, baked into the client from the
|
|
9358
|
+
// Node-side TWITTER_VARIANT_DEFS so the Experiments tab and the job-history
|
|
9359
|
+
// pill describe a variant the same way. JSON.stringify keeps it backslash-free
|
|
9360
|
+
// inside this HTML backtick template (any \\n / \\t in here would silently
|
|
9361
|
+
// blank the page per the documented HTML-template gotcha).
|
|
9362
|
+
const TWITTER_VARIANT_DEFS = ${JSON.stringify(TWITTER_VARIANT_DEFS)};
|
|
9363
|
+
|
|
9168
9364
|
function renderResult(run) {
|
|
9169
9365
|
const r = run.result || {};
|
|
9170
9366
|
const pill = (label, n, _color) =>
|
|
@@ -9380,6 +9576,23 @@ function renderResult(run) {
|
|
|
9380
9576
|
+ r.projects_worked.join(', ')
|
|
9381
9577
|
+ '</span></span>'
|
|
9382
9578
|
: '') +
|
|
9579
|
+
(r.experiment_variant
|
|
9580
|
+
? (function () {
|
|
9581
|
+
const v = r.experiment_variant;
|
|
9582
|
+
const def = TWITTER_VARIANT_DEFS[v] || null;
|
|
9583
|
+
const desc = def ? def.desc : '';
|
|
9584
|
+
const label = def ? def.label : '';
|
|
9585
|
+
const tooltip = 'twitter-ripen-freshness-abc'
|
|
9586
|
+
+ (label ? ' — ' + v + ' ' + label : '')
|
|
9587
|
+
+ (desc ? ': ' + desc : '');
|
|
9588
|
+
return '<span title="' + tooltip.replace(/"/g, '"')
|
|
9589
|
+
+ '" style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
9590
|
+
+ 'experiment '
|
|
9591
|
+
+ '<span style="color:var(--text);font-weight:600;">'
|
|
9592
|
+
+ v + (desc ? ': ' + desc : '')
|
|
9593
|
+
+ '</span></span>';
|
|
9594
|
+
})()
|
|
9595
|
+
: '') +
|
|
9383
9596
|
(Array.isArray(r.styles_used) && r.styles_used.length
|
|
9384
9597
|
? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
9385
9598
|
+ 'styles '
|
|
@@ -10651,6 +10864,106 @@ function stopLogAutoRefresh() {
|
|
|
10651
10864
|
}
|
|
10652
10865
|
|
|
10653
10866
|
// Settings
|
|
10867
|
+
function fmtIntK(n) {
|
|
10868
|
+
if (n == null) return '\u2014';
|
|
10869
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
10870
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
10871
|
+
return String(Math.round(n));
|
|
10872
|
+
}
|
|
10873
|
+
|
|
10874
|
+
function fmtMinHm(min) {
|
|
10875
|
+
if (min == null || !isFinite(min)) return '\u2014';
|
|
10876
|
+
if (min < 60) return Math.round(min) + 'm';
|
|
10877
|
+
const h = Math.floor(min / 60);
|
|
10878
|
+
const m = Math.round(min - h * 60);
|
|
10879
|
+
return h + 'h ' + (m < 10 ? '0' + m : m) + 'm';
|
|
10880
|
+
}
|
|
10881
|
+
|
|
10882
|
+
async function loadExperiments() {
|
|
10883
|
+
const container = document.getElementById('experiments-list');
|
|
10884
|
+
if (!container) return;
|
|
10885
|
+
container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
|
|
10886
|
+
let payload;
|
|
10887
|
+
try {
|
|
10888
|
+
const res = await fetch('/api/experiments');
|
|
10889
|
+
payload = await res.json();
|
|
10890
|
+
if (!res.ok || payload.error) throw new Error(payload.error || ('HTTP ' + res.status));
|
|
10891
|
+
} catch (e) {
|
|
10892
|
+
container.innerHTML = '<div class="style-stats-empty">Failed to load: ' + (e.message || e) + '</div>';
|
|
10893
|
+
return;
|
|
10894
|
+
}
|
|
10895
|
+
const experiments = payload.experiments || [];
|
|
10896
|
+
if (!experiments.length) {
|
|
10897
|
+
container.innerHTML = '<div class="style-stats-empty">No experiments running.</div>';
|
|
10898
|
+
return;
|
|
10899
|
+
}
|
|
10900
|
+
// Top KPI band: count running/decided/shipped across all experiments.
|
|
10901
|
+
const kpiCounts = { running: 0, decided: 0, shipped: 0 };
|
|
10902
|
+
experiments.forEach(e => { if (kpiCounts[e.status] != null) kpiCounts[e.status]++; });
|
|
10903
|
+
const kpiHtml = (
|
|
10904
|
+
'<div class="exp-kpi-band">' +
|
|
10905
|
+
'<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.running + '</span><span class="exp-kpi-label">running</span></div>' +
|
|
10906
|
+
'<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.decided + '</span><span class="exp-kpi-label">decided</span></div>' +
|
|
10907
|
+
'<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.shipped + '</span><span class="exp-kpi-label">shipped</span></div>' +
|
|
10908
|
+
'</div>'
|
|
10909
|
+
);
|
|
10910
|
+
const cards = experiments.map(exp => {
|
|
10911
|
+
const variants = exp.variants || [];
|
|
10912
|
+
const variantRows = variants.map(v => {
|
|
10913
|
+
const postRate = v.post_rate_pct != null ? v.post_rate_pct.toFixed(2) + '%' : '\u2014';
|
|
10914
|
+
const threadAge = fmtMinHm(v.thread_age_min_p50);
|
|
10915
|
+
const views = v.avg_views != null ? fmtIntK(v.avg_views) : '\u2014';
|
|
10916
|
+
const likes = v.avg_likes != null ? fmtIntK(v.avg_likes) : '\u2014';
|
|
10917
|
+
const replies = v.avg_replies != null ? fmtIntK(v.avg_replies) : '\u2014';
|
|
10918
|
+
return (
|
|
10919
|
+
'<tr>' +
|
|
10920
|
+
'<td><span class="exp-variant-key">' + v.key + '</span> <span class="exp-variant-label">' + (v.label || '') + '</span><div class="exp-variant-desc">' + (v.desc || '') + '</div></td>' +
|
|
10921
|
+
'<td class="num">' + fmtIntK(v.n_candidates) + '</td>' +
|
|
10922
|
+
'<td class="num">' + fmtIntK(v.n_batches) + '</td>' +
|
|
10923
|
+
'<td class="num">' + fmtIntK(v.n_posted) + '</td>' +
|
|
10924
|
+
'<td class="num">' + postRate + '</td>' +
|
|
10925
|
+
'<td class="num">' + threadAge + '</td>' +
|
|
10926
|
+
'<td class="num">' + views + '</td>' +
|
|
10927
|
+
'<td class="num">' + likes + '</td>' +
|
|
10928
|
+
'<td class="num">' + replies + '</td>' +
|
|
10929
|
+
'</tr>'
|
|
10930
|
+
);
|
|
10931
|
+
}).join('');
|
|
10932
|
+
const startedTxt = exp.started_at ? new Date(exp.started_at).toLocaleString() : '\u2014';
|
|
10933
|
+
return (
|
|
10934
|
+
'<div class="exp-card">' +
|
|
10935
|
+
'<div class="exp-card-head">' +
|
|
10936
|
+
'<div>' +
|
|
10937
|
+
'<div class="exp-card-title">' + (exp.name || exp.id) + '</div>' +
|
|
10938
|
+
'<div class="exp-card-hypothesis">' + (exp.hypothesis || '') + '</div>' +
|
|
10939
|
+
'</div>' +
|
|
10940
|
+
'<div class="exp-card-meta">' +
|
|
10941
|
+
'<span class="exp-status exp-status-' + exp.status + '">' + exp.status + '</span>' +
|
|
10942
|
+
'<span class="exp-started">started ' + startedTxt + '</span>' +
|
|
10943
|
+
'<span class="exp-totals">' + fmtIntK(exp.totals && exp.totals.n_candidates) + ' candidates / ' + fmtIntK(exp.totals && exp.totals.n_posted) + ' posted</span>' +
|
|
10944
|
+
'<span class="exp-primary">primary: ' + (exp.primary_metric || '\u2014') + '</span>' +
|
|
10945
|
+
'</div>' +
|
|
10946
|
+
'</div>' +
|
|
10947
|
+
'<table class="exp-table">' +
|
|
10948
|
+
'<thead><tr>' +
|
|
10949
|
+
'<th>Variant</th>' +
|
|
10950
|
+
'<th class="num">Cands</th>' +
|
|
10951
|
+
'<th class="num">Batches</th>' +
|
|
10952
|
+
'<th class="num">Posted</th>' +
|
|
10953
|
+
'<th class="num">Post rate</th>' +
|
|
10954
|
+
'<th class="num">Thread age @discover (p50)</th>' +
|
|
10955
|
+
'<th class="num">Views/post</th>' +
|
|
10956
|
+
'<th class="num">Likes/post</th>' +
|
|
10957
|
+
'<th class="num">Replies/post</th>' +
|
|
10958
|
+
'</tr></thead>' +
|
|
10959
|
+
'<tbody>' + variantRows + '</tbody>' +
|
|
10960
|
+
'</table>' +
|
|
10961
|
+
'</div>'
|
|
10962
|
+
);
|
|
10963
|
+
}).join('');
|
|
10964
|
+
container.innerHTML = kpiHtml + cards;
|
|
10965
|
+
}
|
|
10966
|
+
|
|
10654
10967
|
async function loadSettings() {
|
|
10655
10968
|
try {
|
|
10656
10969
|
const [configRes, envRes] = await Promise.all([fetch('/api/config'), fetch('/api/env')]);
|
|
@@ -17427,6 +17740,12 @@ function activateTab(name) {
|
|
|
17427
17740
|
loadSettings();
|
|
17428
17741
|
_tabLoaded.settings = true;
|
|
17429
17742
|
}
|
|
17743
|
+
if (name === 'experiments') {
|
|
17744
|
+
// Always refetch: cycles fire every 15 min, so stale data is more painful
|
|
17745
|
+
// than the cheap pq() roundtrip. Single endpoint, single SQL pass.
|
|
17746
|
+
loadExperiments();
|
|
17747
|
+
_tabLoaded.experiments = true;
|
|
17748
|
+
}
|
|
17430
17749
|
return true;
|
|
17431
17750
|
}
|
|
17432
17751
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
@@ -17447,7 +17766,7 @@ _saInstallDeleteListener();
|
|
|
17447
17766
|
// visible to the user (some tabs are admin-only).
|
|
17448
17767
|
(function restoreActiveTab() {
|
|
17449
17768
|
const saved = saLoad('sa.activeTab.v1', null);
|
|
17450
|
-
const valid = ['status', 'stats', 'trends', 'activity', 'top', 'logs', 'settings'];
|
|
17769
|
+
const valid = ['status', 'stats', 'trends', 'activity', 'top', 'experiments', 'logs', 'settings'];
|
|
17451
17770
|
if (!saved || !valid.includes(saved) || saved === 'stats') return;
|
|
17452
17771
|
// Defer a tick so admin-only visibility (driven by auth init) settles
|
|
17453
17772
|
// before we try to switch.
|
package/package.json
CHANGED
package/schema-postgres.sql
CHANGED
|
@@ -107,6 +107,57 @@ CREATE INDEX IF NOT EXISTS idx_thread_top_replies_refresh
|
|
|
107
107
|
ON thread_top_replies(platform, status, engagement_updated_at)
|
|
108
108
|
WHERE status = 'active';
|
|
109
109
|
|
|
110
|
+
-- 2026-05-22: snowflake-derived posted_at on both rails (see
|
|
111
|
+
-- migrations/2026-05-22-snowflake-derived-posted-at.sql for the full
|
|
112
|
+
-- rationale). Twitter snowflake IDs encode their creation timestamp:
|
|
113
|
+
-- ts_ms = (id >> 22) + 1288834974657. We already store reply_tweet_id
|
|
114
|
+
-- on thread_top_replies and the thread tweet ID lives in
|
|
115
|
+
-- posts.thread_url, so both columns are derivable arithmetically. Using
|
|
116
|
+
-- GENERATED STORED so the routes never need to know.
|
|
117
|
+
ALTER TABLE thread_top_replies
|
|
118
|
+
ADD COLUMN IF NOT EXISTS reply_posted_at TIMESTAMPTZ
|
|
119
|
+
GENERATED ALWAYS AS (
|
|
120
|
+
CASE
|
|
121
|
+
WHEN reply_tweet_id ~ '^\d+$'
|
|
122
|
+
THEN to_timestamp(
|
|
123
|
+
((reply_tweet_id::bigint) >> 22) / 1000.0 + 1288834974.657
|
|
124
|
+
)
|
|
125
|
+
ELSE NULL
|
|
126
|
+
END
|
|
127
|
+
) STORED;
|
|
128
|
+
|
|
129
|
+
ALTER TABLE posts
|
|
130
|
+
ADD COLUMN IF NOT EXISTS thread_posted_at TIMESTAMPTZ
|
|
131
|
+
GENERATED ALWAYS AS (
|
|
132
|
+
CASE
|
|
133
|
+
WHEN thread_url ~ '/status/\d+'
|
|
134
|
+
THEN to_timestamp(
|
|
135
|
+
((substring(thread_url FROM '/status/(\d+)')::bigint) >> 22) / 1000.0 + 1288834974.657
|
|
136
|
+
)
|
|
137
|
+
ELSE NULL
|
|
138
|
+
END
|
|
139
|
+
) STORED;
|
|
140
|
+
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_posts_platform_thread_posted_at
|
|
142
|
+
ON posts (platform, thread_posted_at)
|
|
143
|
+
WHERE thread_posted_at IS NOT NULL;
|
|
144
|
+
|
|
145
|
+
-- 2026-05-22: link metadata on thread_top_replies (see
|
|
146
|
+
-- migrations/2026-05-22-thread-top-replies-link-metadata.sql). The
|
|
147
|
+
-- snapshot now captures min 1, max 2 replies per thread: rank=1 is
|
|
148
|
+
-- top by likes regardless of link; rank=2 is the top link-bearing
|
|
149
|
+
-- reply (if one exists and differs by URL). has_link is a derived
|
|
150
|
+
-- convenience flag for analytics + partial indexes.
|
|
151
|
+
ALTER TABLE thread_top_replies
|
|
152
|
+
ADD COLUMN IF NOT EXISTS reply_link_url TEXT,
|
|
153
|
+
ADD COLUMN IF NOT EXISTS reply_link_display TEXT;
|
|
154
|
+
ALTER TABLE thread_top_replies
|
|
155
|
+
ADD COLUMN IF NOT EXISTS has_link BOOLEAN
|
|
156
|
+
GENERATED ALWAYS AS (reply_link_url IS NOT NULL) STORED;
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_thread_top_replies_has_link
|
|
158
|
+
ON thread_top_replies(post_id, has_link)
|
|
159
|
+
WHERE has_link = TRUE;
|
|
160
|
+
|
|
110
161
|
CREATE TABLE IF NOT EXISTS threads (
|
|
111
162
|
id SERIAL PRIMARY KEY,
|
|
112
163
|
platform TEXT NOT NULL,
|