Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6cabac8
Add pluggable friction stores
Slokh Aug 4, 2026
333d5cd
Expose stored occurrence counts
Slokh Aug 4, 2026
553a3a8
Respect PostgreSQL search paths
Slokh Aug 4, 2026
ed18ff8
Configure CLI friction stores
Slokh Aug 4, 2026
c0ddea2
Simplify Postgres store setup
Slokh Aug 4, 2026
ce222cc
Format CLI store configuration
Slokh Aug 4, 2026
bf300bd
Resolve pnpm toolchain friction
Slokh Aug 4, 2026
566a4b7
Harden pluggable store boundaries
Slokh Aug 4, 2026
7fbbda0
merge main
jxom Aug 4, 2026
2a6581d
refactor: align pluggable stores
jxom Aug 4, 2026
3051a84
test: use postgres testcontainers
jxom Aug 4, 2026
1c18c67
refactor: group postgres options
jxom Aug 4, 2026
69ce3b2
test: namespace postgres fixture
jxom Aug 4, 2026
29c402a
fix: resolve store review feedback
jxom Aug 4, 2026
8fca846
feat: accept postgres connection strings
jxom Aug 4, 2026
f906b82
feat: use postgres.js for stores
jxom Aug 4, 2026
0c570d2
fix: preserve store edge cases
jxom Aug 4, 2026
2b1a59f
feat: migrate stores through frog
jxom Aug 4, 2026
71283ea
docs: describe database store roadmap
jxom Aug 4, 2026
74b1d55
docs: add d1 store roadmap
jxom Aug 4, 2026
6b5f93c
docs: reorder database store guide
jxom Aug 4, 2026
3aa2ac9
fix: guard store parsing boundaries
jxom Aug 4, 2026
3a097a6
docs: simplify database setup
jxom Aug 4, 2026
0239ceb
docs: trim database usage
jxom Aug 4, 2026
b70df92
docs: reorder database storage
jxom Aug 4, 2026
20ecbf0
docs: expand database overview
jxom Aug 4, 2026
ec4ce5a
docs: reuse database URL
jxom Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/calm-frogs-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'frog': minor
---

Add a public friction-store contract, a storage-independent `FrictionLog` API, and an optional
Postgres adapter while preserving the repository file store as the default. `FROG_DATABASE_URL`
automatically selects Postgres for CLI commands, and `frog migrate` prepares the selected store.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,47 @@ ships a reproduction. Exits 1 on an entry that fails to parse, so it doubles as
frog list
```

### Store Logs in Postgres

Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use Postgres
instead, then run the idempotent migration once:

```sh
FROG_DATABASE_URL=postgres://... frog migrate
FROG_DATABASE_URL=postgres://... frog list
```

Install `pg` beside Frog when using Postgres (and `@types/pg` in TypeScript projects).
`FROG_NAMESPACE` can isolate several consumers in one database (it defaults to `default`), and
`FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does
not change Frog's default store.

Applications use the same store through the programmatic API:

```ts
import { FrictionLog, PostgresStore } from 'frog'
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await PostgresStore.migrate({ client: pool })
const store = PostgresStore.adapter({ client: pool, namespace: 'support-agent' })
const frog = new FrictionLog({ store })

const result = await frog.record({
title: 'Search result omitted its freshness',
body: 'The caller could not tell when the result was collected.',
severity: 'major',
context: { source: 'production-agent', execution: 'opaque-reference' },
})

const unresolved = await frog.records() // canonical entries with deduplicated occurrence counts
```

Every store implements the exported `FrictionStore` contract and preserves the same `Entry` fields.
Storage metadata such as occurrence counts stays outside that entry schema. Custom adapters can use a
remote service, SQLite, or another database. Repository and GitHub automation—artifacts, `list --since`,
`log --open`, `log --publish`, `publish`, and `sync`—remains available only with the file store.

### Logging Upstream

Reports friction to another project instead of your own. A target is an npm package or an `owner/repo`,
Expand Down Expand Up @@ -224,7 +265,9 @@ Commands:
init Create the friction log, config, and issue form.
list List entries with their state.
log Write a friction entry.
migrate Create or upgrade the selected store.
publish Report pending entries as GitHub issues.
resolve Remove one resolved friction entry.
sync Reconcile entries against issue state.
targets List dependencies that accept friction reports.

Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@
"incur": "catalog:",
"yaml": "catalog:"
},
"peerDependencies": {
"pg": ">=8.0.0"
},
"peerDependenciesMeta": {
"pg": {
"optional": true
}
},
"engines": {
"node": ">=22"
}
Expand Down
110 changes: 110 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/Entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const Severity = z.enum(severities)

/** Frontmatter of an entry's write-up. */
export type Frontmatter = {
/** Consumer-defined structured context. Frog stores it but does not interpret it. */
context?: Readonly<Record<string, unknown>> | undefined
Comment thread
jxom marked this conversation as resolved.
Outdated
/** Linked issue as `owner/name#number`. Written by publishing, absent while pending. */
issue?: string | undefined
/** Extra issue labels, applied on top of the configured and severity labels. */
Expand All @@ -39,6 +41,7 @@ export type Frontmatter = {
* annotation stops the hand-written type and the schema drifting.
*/
export const Frontmatter: z.ZodType<Frontmatter> = z.object({
context: z.record(z.string(), z.unknown()).optional(),
issue: z
.string()
.regex(/^[\w.-]+\/[\w.-]+#\d+$/)
Expand Down Expand Up @@ -121,11 +124,12 @@ export declare namespace parse {
* @returns File contents, ready to write. Absent optional fields are omitted, not written empty.
*/
export function serialize(entry: serialize.Options): string {
const { body, issue, labels, severity, target, title } = entry
const { body, context, issue, labels, severity, target, title } = entry
const frontmatter = YAML.stringify(
{
title,
severity,
...(context && Object.keys(context).length ? { context } : {}),
...(target ? { target } : {}),
...(labels?.length ? { labels } : {}),
...(issue ? { issue } : {}),
Expand Down
52 changes: 52 additions & 0 deletions src/FrictionLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { tmpdir } from '../test/helpers.js'
import { FrictionLog } from './FrictionLog.js'

const entry = {
body: 'It took an unnecessary workaround.',
severity: 'minor',
title: 'Filters ignored',
} as const

describe('FrictionLog', () => {
test('behavior: defaults to the existing repository-file store', async () => {
const log = new FrictionLog({ root: await tmpdir() })
const result = await log.record(entry)

expect(result).toMatchObject({ created: true, occurrences: 1 })
expect(log.store.name).toBe('file')
expect(await log.list()).toEqual([result.entry])
expect(await log.records()).toEqual([{ entry: result.entry, occurrences: 1 }])
})

test('behavior: deduplicates normalized titles without changing the file-store default', async () => {
const log = new FrictionLog({ root: await tmpdir() })
const first = await log.record(entry)
const repeated = await log.record({ ...entry, title: 'filters: ignored!' })

expect(repeated).toEqual({ created: false, entry: first.entry, occurrences: 1 })
expect(await log.list()).toHaveLength(1)
})

test('behavior: delegates atomic recording to an adapter that provides it', async () => {
const record = vi.fn(async () => ({
created: false,
entry: { ...entry, id: 'existing' },
occurrences: 4,
}))
const log = new FrictionLog({
store: {
name: 'custom',
record,
read: vi.fn(),
list: vi.fn(),
get: vi.fn(),
write: vi.fn(),
remove: vi.fn(),
files: vi.fn(),
},
})

await expect(log.record(entry)).resolves.toMatchObject({ created: false, occurrences: 4 })
expect(record).toHaveBeenCalledWith(entry, {})
})
})
Loading
Loading