Skip to content

Commit 0295b0a

Browse files
committed
fix: audit all packagist changed-fields consistently
Signed-off-by: anilb <epipav@gmail.com>
1 parent a314c70 commit 0295b0a

11 files changed

Lines changed: 110 additions & 54 deletions

File tree

docs/adr/0009-packagist-worker-design-decisions.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# ADR-0006: Packagist worker — design decisions (living)
1+
# ADR-0009: Packagist worker — design decisions (living)
22

33
**Date**: 2026-07-13
4-
**Status**: living
4+
**Status**: accepted
55
**Deciders**: Anil Bostanci
66

77
_Living, consolidated record for the Packagist (PHP/Composer) worker. Record further
@@ -38,8 +38,8 @@ We store, in `package_dependencies`, **only the direct dependency edges a versio
3838
row per `(version_id, depends_on_id, dependency_kind)` — and we **resolve concrete versions and
3939
transitive closure at query time**, never at ingest.
4040

41-
Per stored edge (for the critical slice only — dependency edges ride the same p2 metadata fetch as
42-
versions, which we only run for critical packages):
41+
Per stored edge (for **all** packages — dependency edges ride the same p2 metadata fetch as
42+
versions; see the Metadata enrichment scope decision):
4343

4444
- `require``dependency_kind = 'direct'`, `require-dev``dependency_kind = 'dev'`.
4545
- `version_constraint` = the **declared** constraint string verbatim (e.g. `^3.0 || ^2.0`).
@@ -75,8 +75,7 @@ continuously re-running a Composer resolver for no durable benefit.
7575
**Why store `dev` edges when deps.dev doesn't:** deps.dev's resolved graph is runtime-only — every
7676
edge lands as `'direct'`, it has no dev/peer notion. Because we read the raw manifest we can split
7777
`require-dev` into `'dev'`, so Packagist edges are *richer per-edge* (dev deps + declared
78-
constraints) even though they are *shallower* (no resolved target version) and *narrower* (critical
79-
slice only) than deps.dev's.
78+
constraints) even though they are *shallower* (no resolved target version) than deps.dev's.
8079

8180
**Consequences.**
8281

@@ -98,8 +97,6 @@ _Negative / trade-offs:_
9897
way there is for npm/maven/pypi/cargo seeded from deps.dev.
9998
- Transitive questions ("everything X depends on") pay a recursive walk over direct edges at read
10099
time rather than a single indexed lookup.
101-
- Dependency edges exist for the **critical slice only** — non-critical Packagist packages have
102-
none, because the p2 metadata crawl that produces them is critical-scoped.
103100

104101
_Risks:_
105102

@@ -198,3 +195,6 @@ that week's enrichment (recoverable via the manual trigger).
198195
covers repo linking for all packages and the deliberate critical-only carve-outs).
199196
- **2026-07-16** — Added _Lane architecture: seed → chained enrichment, downloads as dedicated
200197
lanes_ (removes the stats lane; renames `p2_last_modified``metadata_last_modified`).
198+
- **2026-07-17** — Corrected stale "critical slice only" wording left over in the _Dependency
199+
model_ entry from before the _Metadata enrichment scope_ decision widened it to all packages;
200+
set `Status` to `accepted` (matching ADR-0005's precedent for a living/consolidated doc).

docs/adr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
1515
| [ADR-0006](./0006-database-schema-types-as-source-of-truth.md) | Database schema types as the source of truth | accepted | 2026-07-09 |
1616
| [ADR-0007](./0007-test-factory-primitives-and-defaults.md) | Test factory primitives and defaults | accepted | 2026-07-10 |
1717
| [ADR-0008](./0008-how-we-write-unit-tests.md) | How we write unit tests | accepted | 2026-07-13 |
18-
| [ADR-0009](./0009-packagist-worker-design-decisions.md) | Packagist worker — design decisions (living) | living | 2026-07-13 |
18+
| [ADR-0009](./0009-packagist-worker-design-decisions.md) | Packagist worker — design decisions | accepted | 2026-07-13 |
1919

2020
## Why ADRs?
2121

services/apps/packages_worker/src/packagist/__tests__/downloads.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,30 @@ describe('monthlyWindowFor', () => {
5050
// packages.downloads_last_30d (npm parity). A window already recorded for the month
5151
// is never overwritten — the value is the observation closest to the boundary.
5252
describe('persistPackagist30dWindow', () => {
53-
it('writes the window with the mirror when the month has no row yet', async () => {
54-
await persistPackagist30dWindow(qx, PURL, 300, '2026-07-01')
53+
it('writes the window with the mirror and returns the changed fields when the month has no row yet', async () => {
54+
mockWindow.mockResolvedValue(['downloads_last_30d.count', 'packages.downloads_last_30d'])
55+
56+
const changedFields = await persistPackagist30dWindow(qx, PURL, 300, '2026-07-01')
5557

5658
expect(mockExistingWindows).toHaveBeenCalledWith(qx, PURL, '2026-07-01', '2026-07-01')
5759
expect(mockWindow).toHaveBeenCalledWith(qx, PURL, '2026-06-01', '2026-07-01', 300, true)
60+
expect(changedFields).toEqual(['downloads_last_30d.count', 'packages.downloads_last_30d'])
5861
})
5962

60-
it('does not overwrite an existing window for the month', async () => {
63+
it('does not overwrite an existing window for the month, and returns no changed fields', async () => {
6164
mockExistingWindows.mockResolvedValue(['2026-07-01'])
6265

63-
await persistPackagist30dWindow(qx, PURL, 300, '2026-07-15')
66+
const changedFields = await persistPackagist30dWindow(qx, PURL, 300, '2026-07-15')
6467

6568
expect(mockWindow).not.toHaveBeenCalled()
69+
expect(changedFields).toEqual([])
6670
})
6771

68-
it('writes nothing when the registry reported no monthly count', async () => {
69-
await persistPackagist30dWindow(qx, PURL, null, '2026-07-01')
72+
it('writes nothing and returns no changed fields when the registry reported no monthly count', async () => {
73+
const changedFields = await persistPackagist30dWindow(qx, PURL, null, '2026-07-01')
7074

7175
expect(mockExistingWindows).not.toHaveBeenCalled()
7276
expect(mockWindow).not.toHaveBeenCalled()
77+
expect(changedFields).toEqual([])
7378
})
7479
})

services/apps/packages_worker/src/packagist/__tests__/ingest.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('ingestOnePackagistMetadata', () => {
110110
})
111111
}
112112

113-
it('persists both endpoints, audits the merged fields, and marks scanned with the fresh Last-Modified', async () => {
113+
it('audits phase 1 immediately and phase 2 separately, then marks scanned with the fresh Last-Modified', async () => {
114114
happyMocks()
115115

116116
await ingestOnePackagistMetadata(qx, candidate)
@@ -119,12 +119,10 @@ describe('ingestOnePackagistMetadata', () => {
119119
expect(mockFetchP2).toHaveBeenCalledWith('monolog/monolog', 'Tue, 30 Jun 2026 00:00:00 GMT')
120120
expect(mockExpand).toHaveBeenCalledWith(minified)
121121
expect(mockPersistMetadata).toHaveBeenCalledWith(qx, PURL, expanded)
122-
expect(mockAudit).toHaveBeenCalledWith(
123-
qx,
124-
'packagist',
125-
PURL,
126-
expect.arrayContaining(['packages.description', 'versions.number']),
127-
)
122+
// Two separate audit rows, not one merged call — phase 1 is logged as soon as it's
123+
// committed, before the p2 fetch (which can throw) ever runs.
124+
expect(mockAudit).toHaveBeenNthCalledWith(1, qx, 'packagist', PURL, ['packages.description'])
125+
expect(mockAudit).toHaveBeenNthCalledWith(2, qx, 'packagist', PURL, ['versions.number'])
128126
expect(mockMarkMetadata).toHaveBeenCalledWith(
129127
qx,
130128
PURL,
@@ -227,24 +225,32 @@ describe('ingestOnePackagistMetadata', () => {
227225
)
228226
})
229227

230-
it('throws on a transient p2 result without marking scanned', async () => {
228+
it('throws on a transient p2 result without marking scanned, but still audits phase 1', async () => {
231229
mockFetchStats.mockResolvedValue(statsJson as never)
232-
mockPersistInfo.mockResolvedValue({ found: true, changedFields: [] })
230+
mockPersistInfo.mockResolvedValue({ found: true, changedFields: ['packages.description'] })
233231
mockFetchP2.mockResolvedValue({ kind: 'TRANSIENT', message: 'HTTP 502' } as never)
234232

235233
await expect(ingestOnePackagistMetadata(qx, candidate)).rejects.toThrow()
236234
expect(mockMarkMetadata).not.toHaveBeenCalled()
235+
// phase-1 writes are already committed when the throw happens — a retry re-runs
236+
// phase 1 idempotently and reports no changes, so this audit event can't be deferred
237+
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, ['packages.description'])
237238
})
238239
})
239240

240241
// The monthly downloads-30d lane: dynamic fetch, window row only.
241242
describe('ingestOnePackagist30dWindow', () => {
242-
it('persists the observed rolling window and marks the run processed', async () => {
243+
it('persists the observed rolling window, audits the change, and marks the run processed', async () => {
243244
mockFetchStats.mockResolvedValue(statsJson as never)
245+
mockPersist30d.mockResolvedValue(['downloads_last_30d.count', 'packages.downloads_last_30d'])
244246

245247
await ingestOnePackagist30dWindow(qx, PURL, RUN_DATE)
246248

247249
expect(mockPersist30d).toHaveBeenCalledWith(qx, PURL, 300, RUN_DATE)
250+
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, [
251+
'downloads_last_30d.count',
252+
'packages.downloads_last_30d',
253+
])
248254
expect(mockMark30d).toHaveBeenCalledWith(
249255
qx,
250256
PURL,
@@ -284,25 +290,31 @@ describe('ingestOnePackagist30dWindow', () => {
284290
describe('ingestOnePackagistDailyDownload', () => {
285291
const candidate = { purl: PURL, packageId: '7' }
286292

287-
it('inserts the daily row and marks the run processed', async () => {
293+
it('inserts the daily row, audits the change, and marks the run processed', async () => {
288294
mockFetchStats.mockResolvedValue(statsJson as never)
295+
mockDaily.mockResolvedValue(['downloads_daily.date', 'downloads_daily.count'])
289296

290297
await ingestOnePackagistDailyDownload(qx, candidate, RUN_DATE)
291298

292299
expect(mockDaily).toHaveBeenCalledWith(qx, '7', [{ day: RUN_DATE, downloads: 10 }])
300+
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, [
301+
'downloads_daily.date',
302+
'downloads_daily.count',
303+
])
293304
expect(mockMarkDaily).toHaveBeenCalledWith(
294305
qx,
295306
PURL,
296307
expect.objectContaining({ status: 'success' }),
297308
)
298309
})
299310

300-
it('marks success without inserting when the registry reports no daily count', async () => {
311+
it('marks success without inserting or auditing when the registry reports no daily count', async () => {
301312
mockFetchStats.mockResolvedValue({ package: { name: 'monolog/monolog' } } as never)
302313

303314
await ingestOnePackagistDailyDownload(qx, candidate, RUN_DATE)
304315

305316
expect(mockDaily).not.toHaveBeenCalled()
317+
expect(mockAudit).not.toHaveBeenCalled()
306318
expect(mockMarkDaily).toHaveBeenCalledWith(
307319
qx,
308320
PURL,

services/apps/packages_worker/src/packagist/__tests__/persistMetadata.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vi.mock('@crowd/data-access-layer/src/packages', () => ({
1515
updatePackagistVersionAggregates: vi.fn(),
1616
upsertPackagistVersions: vi.fn(),
1717
getPackagistPackageIdsByNames: vi.fn(),
18-
upsertVersionDependencies: vi.fn().mockResolvedValue(0),
18+
upsertVersionDependencies: vi.fn().mockResolvedValue([]),
1919
}))
2020

2121
const mockAggregates = vi.mocked(updatePackagistVersionAggregates)
@@ -48,7 +48,7 @@ const expanded: PackagistExpandedVersion[] = [
4848

4949
beforeEach(() => {
5050
vi.clearAllMocks()
51-
mockDeps.mockResolvedValue(0)
51+
mockDeps.mockResolvedValue([])
5252
})
5353

5454
// C2 + C3 — persistence wiring: aggregates on packages, version rows, dependency edges
@@ -69,6 +69,7 @@ describe('persistPackagistMetadata', () => {
6969
['phpunit/phpunit', '45'],
7070
]),
7171
)
72+
mockDeps.mockResolvedValue(['package_dependencies.depends_on_id'])
7273

7374
const result = await persistPackagistMetadata(qx, PURL, expanded)
7475

@@ -113,6 +114,8 @@ describe('persistPackagistMetadata', () => {
113114

114115
expect(result.found).toBe(true)
115116
expect(result.unresolvedDependencyTargets).toBe(0)
117+
// dependency-edge changes must reach the audit log alongside aggregates/versions
118+
expect(result.changedFields).toContain('package_dependencies.depends_on_id')
116119
})
117120

118121
it('skips and counts dependency targets that do not resolve to a packages row', async () => {
Binary file not shown.

services/apps/packages_worker/src/packagist/activities.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ export async function ingestOnePackagistMetadata(
138138

139139
const stats = normalizePackagistStats(info.value.package)
140140
const persistedInfo = await persistPackagistPackageInfo(qx, candidate.purl, stats)
141-
const changedFields = [...persistedInfo.changedFields]
141+
// Audit phase 1 immediately: those writes are already committed
142+
await logAuditFieldChanges(qx, WORKER, candidate.purl, persistedInfo.changedFields)
142143

143144
// Phase 2: p2 endpoint
144145
const p2 = await fetchWithFastRetry(
@@ -150,13 +151,12 @@ export async function ingestOnePackagistMetadata(
150151
{ purl: candidate.purl, statusCode: p2.error.statusCode, kind: p2.error.kind },
151152
'packagist metadata 4xx/malformed after fast retries — marking scanned and skipping',
152153
)
153-
// Phase-1 writes are already committed — their audit rows must not be dropped.
154-
await logAuditFieldChanges(qx, WORKER, candidate.purl, changedFields)
155154
await markPackagistMetadataScanned(qx, candidate.purl, giveUpResult(p2.error))
156155
return
157156
}
158157

159158
let lastModified: string | null = null
159+
let phase2ChangedFields: string[] = []
160160
if (!isP2NotModified(p2.value)) {
161161
const expanded = expandComposerMetadata(p2.value.minifiedVersions)
162162
const persistResult = await persistPackagistMetadata(qx, candidate.purl, expanded)
@@ -166,11 +166,11 @@ export async function ingestOnePackagistMetadata(
166166
'packagist dependency targets not found in packages — edges skipped',
167167
)
168168
}
169-
changedFields.push(...persistResult.changedFields)
169+
phase2ChangedFields = persistResult.changedFields
170170
lastModified = p2.value.lastModified
171171
}
172172

173-
await logAuditFieldChanges(qx, WORKER, candidate.purl, changedFields)
173+
await logAuditFieldChanges(qx, WORKER, candidate.purl, phase2ChangedFields)
174174
if (lastModified) {
175175
await markPackagistMetadataScanned(
176176
qx,
@@ -207,7 +207,13 @@ export async function ingestOnePackagist30dWindow(
207207
return
208208
}
209209

210-
await persistPackagist30dWindow(qx, purl, info.value.package.downloads?.monthly ?? null, runDate)
210+
const changedFields = await persistPackagist30dWindow(
211+
qx,
212+
purl,
213+
info.value.package.downloads?.monthly ?? null,
214+
runDate,
215+
)
216+
await logAuditFieldChanges(qx, WORKER, purl, changedFields)
211217
await markPackagist30dProcessed(qx, purl, { status: 'success', attempts: info.attempts })
212218
}
213219

@@ -234,7 +240,10 @@ export async function ingestOnePackagistDailyDownload(
234240

235241
const daily = info.value.package.downloads?.daily
236242
if (typeof daily === 'number') {
237-
await insertDailyDownloads(qx, candidate.packageId, [{ day: runDate, downloads: daily }])
243+
const changedFields = await insertDailyDownloads(qx, candidate.packageId, [
244+
{ day: runDate, downloads: daily },
245+
])
246+
await logAuditFieldChanges(qx, WORKER, candidate.purl, changedFields)
238247
}
239248
await markPackagistDailyProcessed(qx, candidate.purl, {
240249
status: 'success',

services/apps/packages_worker/src/packagist/downloads.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@ export function monthlyWindowFor(runDate: string): { startDate: string; endDate:
2121

2222
// One window row per purl per month, mirrored to packages.downloads_last_30d
2323
// (npm parity). A window already recorded for the month is never overwritten —
24-
// the value is the observation closest to the boundary.
24+
// the value is the observation closest to the boundary. Returns the changed
25+
// fields (like the sibling persist*/upsert* functions) — the caller audits them.
2526
export async function persistPackagist30dWindow(
2627
qx: QueryExecutor,
2728
purl: string,
2829
monthly: number | null,
2930
runDate: string,
30-
): Promise<void> {
31-
if (monthly == null) return
31+
): Promise<string[]> {
32+
if (monthly == null) return []
3233

3334
const { startDate, endDate } = monthlyWindowFor(runDate)
3435
const existing = await getExistingLast30dEndDates(qx, purl, endDate, endDate)
3536
if (existing.length === 0) {
36-
await upsertLast30dDownload(qx, purl, startDate, endDate, monthly, true)
37+
return upsertLast30dDownload(qx, purl, startDate, endDate, monthly, true)
3738
}
39+
return []
3840
}

services/apps/packages_worker/src/packagist/upsertMetadata.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ export async function persistPackagistMetadata(
8888
}
8989

9090
if (edges.length > 0) {
91-
await upsertVersionDependencies(qx, edges)
91+
const depChanges = await upsertVersionDependencies(qx, edges)
92+
changedFields.push(...depChanges)
9293
}
9394
}
9495

services/apps/packages_worker/src/packagist/upsertPackageInfo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export async function persistPackagistPackageInfo(
5353

5454
// Step 3: Maintainers only for critical packages
5555
if (isCritical && stats.maintainers.length > 0) {
56-
await upsertPackageMaintainers(qx, id, stats.maintainers, 'packagist')
56+
const maintainerChanges = await upsertPackageMaintainers(qx, id, stats.maintainers, 'packagist')
57+
changedFields.push(...maintainerChanges)
5758
}
5859

5960
return { found: true, changedFields }

0 commit comments

Comments
 (0)