Skip to content

Commit 846b305

Browse files
Merge pull request #164 from JarvusInnovations/develop
Release: v1.0.5
2 parents 33fc63e + 4784e80 commit 846b305

14 files changed

Lines changed: 537 additions & 66 deletions

.github/workflows/publish-npm.yml

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,11 @@ jobs:
1313
steps:
1414
- uses: actions/checkout@v6
1515

16-
- uses: actions/setup-node@v4
16+
- uses: actions/setup-node@v6
1717
with:
18-
node-version: '22.x'
18+
node-version: '24'
1919
registry-url: 'https://registry.npmjs.org'
20-
21-
- name: Install npm via corepack (bypasses broken bundled npm in current Node 22 toolcache)
22-
run: |
23-
corepack enable
24-
corepack install --global npm@latest
25-
npm --version
20+
package-manager-cache: false # never use caching in release builds
2621

2722
- name: Set package.json version from tag
2823
run: |

docs/concepts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ const daemon = await repo.startPushDaemon({
158158
});
159159

160160
daemon.on('push', ({ commit, durationMs }) => log.info({ commit, durationMs }));
161-
daemon.on('error', ({ commit, err, attempt }) => log.warn({ err, attempt }));
162-
daemon.on('retry', ({ commit, attempt, nextDelayMs }) => log.info({ attempt, nextDelayMs }));
161+
daemon.on('error', ({ commit, err, attempt, reason }) => log.warn({ err, attempt, reason }));
162+
daemon.on('retry', ({ commit, attempt, nextDelayMs, reason }) => log.info({ attempt, nextDelayMs, reason }));
163163

164164
// ... later, at shutdown:
165165
await daemon.stop({ timeoutMs: 30_000 });

docs/recipes/production-push-daemon.md

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,16 @@ daemon.on('push', ({ commit, durationMs }) => {
5151
log.info({ commit, durationMs }, 'pushed');
5252
});
5353

54-
daemon.on('retry', ({ commit, attempt, nextDelayMs }) => {
55-
log.info({ commit, attempt, nextDelayMs }, 'push retry scheduled');
54+
daemon.on('retry', ({ commit, attempt, nextDelayMs, reason }) => {
55+
log.info({ commit, attempt, nextDelayMs, reason }, 'push retry scheduled');
5656
});
5757

58-
daemon.on('error', ({ commit, err, attempt }) => {
59-
log.warn({ commit, err: String(err), attempt }, 'push failed');
58+
daemon.on('error', ({ commit, err, attempt, reason }) => {
59+
// `reason: 'non-fast-forward'` means the remote has work this daemon
60+
// doesn't — page someone. The daemon will *not* retry it; pushing the same
61+
// commit again would just re-fail. Future commits still trigger fresh
62+
// attempts (each producing its own classified error).
63+
log.warn({ commit, err: String(err), attempt, reason }, 'push failed');
6064
});
6165

6266
daemon.on('stopped', () => {
@@ -71,7 +75,7 @@ const status = daemon.status();
7175
// {
7276
// running: boolean,
7377
// lastPushAt: ISO 8601 | null,
74-
// lastError: { message, at, attempt } | null,
78+
// lastError: { message, at, attempt, reason: 'non-fast-forward' | 'unknown' } | null,
7579
// pendingCommits: number,
7680
// currentBackoffMs: number | null,
7781
// currentAttempt: number | null,
@@ -161,22 +165,40 @@ process.on('SIGINT', shutdown);
161165
1. Stops accepting new commits to the queue
162166
2. Waits for the in-flight push to complete or fail
163167
3. Drains the retry queue up to `timeoutMs` (default 30s)
164-
4. After the timeout, abandons remaining queued commits (they stay in the local repo and are picked up on the next daemon start, once #157 lands)
168+
4. After the timeout, abandons remaining queued commits they stay in the local repo and are picked up on the next daemon start via the startup-backlog check
165169
5. Resolves once the daemon is fully idle
166170

167171
For Kubernetes / Docker, set `terminationGracePeriodSeconds` to at least `timeoutMs` + a safety margin so the orchestrator doesn't SIGKILL mid-drain.
168172

169173
## Non-fast-forward rejections
170174

171-
Currently the daemon retries every failure including non-fast-forward rejections. That's the v1.0 simplification — production-grade detection (stop retrying, escalate to a human) is tracked at [#156](https://github.com/JarvusInnovations/gitsheets/issues/156). For now, if your remote rejects a push as non-fast-forward, the daemon spins on it until you intervene. Watch the `error` event stream and surface persistent failures.
175+
When the remote contains work the daemon doesn't (a fast-forward isn't possible), the push fails and the daemon classifies it as terminal:
172176

173-
The right intervention when this happens: stop your consumer, investigate the remote (something else committed to your branch), reconcile, restart.
177+
- Exactly one `error` event fires with `reason: 'non-fast-forward'`.
178+
- **No `retry` event follows.** The daemon never force-pushes; pushing the same commit again would just re-fail.
179+
- `daemon.status().lastError.reason` carries `'non-fast-forward'` so health checks / alerting can branch on it.
180+
- Subsequent `repo.transact` commits still trigger fresh push attempts. Each will also fail with `non-fast-forward` (and emit its own event) until the remote state is reconciled.
174181

175-
## Multi-remote replication (deferred)
182+
The right intervention: stop the consumer, investigate the remote (something else committed to your branch), reconcile (rebase or merge), restart.
176183

177-
A `Repository` can host one push daemon at a time, and post-commit notifications fire only on the `Repository` instance that ran the `transact` — so a second `Repository` opened against the same `gitDir` wouldn't see those commits and wouldn't push them. In-process multi-remote replication isn't a working pattern in v1.0.
184+
```typescript
185+
daemon.on('error', ({ reason, err }) => {
186+
if (reason === 'non-fast-forward') {
187+
// page on-call; do not auto-restart
188+
alerts.send({ severity: 'critical', message: 'push rejected — remote diverged' });
189+
} else {
190+
log.warn({ err: String(err) }, 'transient push failure');
191+
}
192+
});
193+
```
178194

179-
This becomes workable once [#157 (push daemon startup-diff)](https://github.com/JarvusInnovations/gitsheets/issues/157) lands and a daemon catches up from the ref state rather than from in-process notifications. Until then, the right replication path is external — e.g., a `git push backup` triggered by an external scheduler or by a server-side hook on the primary remote.
195+
## Multi-remote replication (still external)
196+
197+
A `Repository` can host one push daemon at a time, and post-commit notifications fire only on the `Repository` instance that ran the `transact` — so a second `Repository` opened against the same `gitDir` wouldn't see live commits via the in-process hook. The startup-backlog check runs *once* per daemon (at start), so a long-lived second daemon won't catch up on commits made after it started.
198+
199+
In-process multi-remote replication isn't the right pattern. For a backup remote, drive it externally: an external scheduler triggering `git push backup main`, or a server-side hook on the primary that mirrors to the backup.
200+
201+
What the startup-backlog *does* unlock: if your process restarts (intentional deploy, crash, OOM kill) with commits ahead of the remote, the new daemon pushes them on startup without needing an explicit `repo.transact` to nudge it.
180202

181203
## A complete production setup
182204

@@ -202,8 +224,11 @@ if (process.env.PUSH_REMOTE) {
202224
maxRetries: Infinity,
203225
});
204226
pushDaemon.on('push', ({ commit, durationMs }) => log.info({ commit, durationMs }, 'pushed'));
205-
pushDaemon.on('error', ({ err, attempt }) => log.warn({ err: String(err), attempt }, 'push failed'));
206-
pushDaemon.on('retry', ({ attempt, nextDelayMs }) => log.info({ attempt, nextDelayMs }, 'push retry'));
227+
pushDaemon.on('error', ({ err, attempt, reason }) => {
228+
log.warn({ err: String(err), attempt, reason }, 'push failed');
229+
if (reason === 'non-fast-forward') alerts.page({ message: 'gitsheets remote diverged' });
230+
});
231+
pushDaemon.on('retry', ({ attempt, nextDelayMs, reason }) => log.info({ attempt, nextDelayMs, reason }, 'push retry'));
207232
log.info({ remote: process.env.PUSH_REMOTE }, 'push daemon started');
208233
}
209234

docs/recipes/request-bound-transactions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ import { repo } from './store.js';
8787

8888
if (process.env.NODE_ENV === 'production') {
8989
const daemon = await repo.startPushDaemon({ remote: 'origin' });
90-
daemon.on('error', ({ err, attempt }) =>
91-
console.error('[push-daemon]', err, 'attempt', attempt),
90+
daemon.on('error', ({ err, attempt, reason }) =>
91+
console.error('[push-daemon]', err, 'attempt', attempt, 'reason', reason),
9292
);
9393
}
9494
```

specs/api/conventions.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
Type-only exports (interfaces, type aliases) flow alongside the value exports
3030
above. Notable ones: `TransactionResult`, `TransactionOptions`, `Author`,
3131
`SheetConfig`, `UpsertResult`, `IndexKeyFn`, `DefineIndexOptions`,
32-
`QueryFilter`, `OpenStoreOptions`, `Store`, `StoreTx`, `InferRecord`,
32+
`QueryFilter`, `QueryOptions`, `OpenStoreOptions`, `Store`, `StoreTx`, `InferRecord`,
3333
`PushDaemonOptions`, `PushDaemonStatus`, `BackoffConfig`, `JSONSchema`,
3434
`StandardSchemaV1`, `ValidationIssue`, `RecordLike`.
3535

@@ -67,17 +67,19 @@ Every error thrown by gitsheets extends `GitsheetsError` and carries a stable `c
6767

6868
## Cancellation
6969

70-
Long-running iterations (`Sheet.query`) honor `AbortSignal` when one is passed in via the options. Without a signal, they run to completion.
70+
Long-running iterations (`Sheet.query`, `Sheet.queryFirst`, `Sheet.queryAll`) honor `AbortSignal` when one is passed in via the options. Without a signal, they run to completion.
7171

7272
```typescript
7373
const controller = new AbortController();
7474
for await (const record of sheet.query({}, { signal: controller.signal })) {
7575
// ...
76-
if (someCondition) controller.abort();
76+
if (someCondition) controller.abort(); // optional reason arg → signal.reason
7777
}
7878
```
7979

80-
`AbortError` is thrown after the next yield point. (Defer if implementation cost is high for v1.0; documented here as the convention for any iterator that adds cancellation later.)
80+
When the signal aborts, the next iteration (or the call itself, if aborted before invocation) throws `signal.reason` — which is whatever value was passed to `controller.abort(reason)`, or a `DOMException` with name `'AbortError'` if no reason was supplied. Callers can `try/catch` on the iteration and switch on the thrown value's shape.
81+
82+
Any iterator added later in the public surface should follow the same convention (`opts.signal`, check before iteration, check at each yield).
8183

8284
## Async iteration over trees
8385

specs/api/sheet.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ for await (const user of sheet.query({ accountLevel: 'staff' })) {
2525
- `filter` is a plain object. Each key on the filter is matched against the record's field by equality. Function-valued filter entries are called as `(recordValue, record) => boolean`. Nested objects descend recursively.
2626
- If the filter includes fields from the path template, gitsheets prunes the tree traversal to only matching subtrees (see [behaviors/path-templates.md](../behaviors/path-templates.md)).
2727
- Order of results: filesystem order within each tree level. Not guaranteed stable across implementation changes — sort in consumer code if order matters.
28-
- `opts.signal?: AbortSignal`see [api/conventions.md](conventions.md#cancellation).
28+
- `opts.signal?: AbortSignal`cancel a streaming query. The query checks the signal before iteration starts and again before each yield. If aborted, the next iteration throws `signal.reason` (a `DOMException` with name `'AbortError'` by default, or whatever value the consumer passed to `controller.abort(reason)`). See [api/conventions.md](conventions.md#cancellation).
2929

30-
### `sheet.queryFirst(filter?)`
30+
### `sheet.queryFirst(filter?, opts?)`
3131

32-
Returns `Promise<T | undefined>`. The first match, or `undefined`.
32+
Returns `Promise<T | undefined>`. The first match, or `undefined`. Honors the same `opts.signal` as `query`.
3333

34-
### `sheet.queryAll(filter?)`
34+
### `sheet.queryAll(filter?, opts?)`
3535

36-
Returns `Promise<T[]>`. All matches collected into an array. Convenience over `for await ... push`.
36+
Returns `Promise<T[]>`. All matches collected into an array. Convenience over `for await ... push`. Honors the same `opts.signal` as `query`.
3737

3838
### `sheet.pathForRecord(record)`
3939

specs/behaviors/push-sync.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ If a consumer needs to incorporate changes from elsewhere (a manual edit, a sepa
3636

3737
The underlying operation is `git push <remote> <branch>` with no `--force`. **Regular fast-forward only.**
3838

39-
If the remote rejects the push (non-fast-forward, "remote contains work that you do not have"), the daemon does not retry — it emits an `error` event and surfaces a hard error in `daemon.status()`. The consumer needs to intervene; a never-force-push policy is non-negotiable.
39+
If the remote rejects the push (non-fast-forward, "remote contains work that you do not have"), the daemon does not retry — it emits an `error` event with `reason: 'non-fast-forward'` and surfaces a hard error in `daemon.status()` (also with `lastError.reason === 'non-fast-forward'`). The consumer needs to intervene; a never-force-push policy is non-negotiable.
4040

4141
The daemon does *not* attempt to merge or rebase. The local commit history is the source of truth.
4242

43+
Subsequent `repo.transact` commits will trigger fresh push attempts. If the remote is still ahead, those attempts also fail and emit their own `non-fast-forward` errors — each commit gets one classified error, never a retry storm.
44+
4345
## Backoff
4446

4547
Default: exponential, base 1 second, multiplier 2, cap 1 hour.
@@ -72,16 +74,20 @@ Or accept the default with `backoff: 'exponential'`.
7274

7375
```typescript
7476
daemon.on('push', ({ commit, durationMs }) => { ... });
75-
daemon.on('retry', ({ commit, attempt, nextDelayMs }) => { ... });
76-
daemon.on('error', ({ commit, err, attempt }) => { ... });
77+
daemon.on('retry', ({ commit, attempt, nextDelayMs, reason }) => { ... });
78+
daemon.on('error', ({ commit, err, attempt, reason }) => { ... });
7779
daemon.on('stopped', () => { ... });
7880
```
7981

8082
- `push` — successful push. `durationMs` is wall-clock for the push attempt.
81-
- `retry` — a failed attempt has been scheduled for retry. Fires *before* the delay.
82-
- `error` — a failure occurred. May or may not be retried; if retried, a `retry` event follows.
83+
- `retry` — a failed attempt has been scheduled for retry. Fires *before* the delay. `reason` mirrors the upstream `error`'s classification.
84+
- `error` — a failure occurred. `reason` is one of:
85+
- `'non-fast-forward'` — terminal. No `retry` event follows. Status' `lastError.reason` carries the same value.
86+
- `'unknown'` — anything else (network, auth, transient git errors). Retried per the configured backoff unless `maxRetries` is exhausted.
8387
- `stopped` — emitted after `daemon.stop()` completes draining.
8488

89+
For startup-backlog errors (see "Startup backlog" below) the `commit` field is `null` and `attempt` is `0`; the failure surfaces through the same `error` event channel.
90+
8591
Consumers building observability surfaces typically attach to `push`, `retry`, `error` for Prometheus / structured-logging fan-out.
8692

8793
## Status
@@ -91,7 +97,12 @@ daemon.status();
9197
// → {
9298
// running: boolean,
9399
// lastPushAt: ISO 8601 | null,
94-
// lastError: { message: string, at: ISO 8601, attempt: number } | null,
100+
// lastError: {
101+
// message: string,
102+
// at: ISO 8601,
103+
// attempt: number,
104+
// reason: 'non-fast-forward' | 'unknown',
105+
// } | null,
95106
// pendingCommits: number,
96107
// currentBackoffMs: number | null, // current delay, if a retry is scheduled
97108
// currentAttempt: number | null, // which attempt we're on for the next commit
@@ -136,6 +147,16 @@ Pushing a commit that's already on the remote is a no-op (`Everything up-to-date
136147

137148
If the daemon restarts and finds commits in the local repo that aren't on the remote, it pushes them. The daemon doesn't track a "pending" state across restarts — it diffs local vs. remote on startup and queues anything ahead.
138149

150+
## Startup backlog
151+
152+
`repo.startPushDaemon` returns the handle synchronously to the caller, then performs the startup-backlog check on the next event-loop tick (so consumers can attach `error` / `push` listeners first):
153+
154+
1. `git fetch <remote> <branch>` — best-effort, populates `refs/remotes/<remote>/<branch>`. If this fails (unreachable remote, auth) the daemon emits an `error` event with `commit: null`, `attempt: 0`, classified `reason`. The check continues — a previously-cached remote-tracking ref may still be usable.
155+
2. `git rev-list --count <remote>/<branch>..<branch>` — counts local commits the remote doesn't have. If the remote-tracking ref doesn't exist after the fetch (e.g., the branch was never on the remote), the check exits silently; the daemon stays alive for future `notifyCommit` events.
156+
3. If the count is non-zero, the daemon primes its pending counter and immediately drains — the queued commits flow through the same push pipeline as live ones.
157+
158+
The startup check **never throws**. The consumer's `await repo.startPushDaemon(opts)` resolves with the handle even when the remote is unreachable; observability comes through `error` events and `daemon.status()`.
159+
139160
## Push frequency
140161

141162
The daemon pushes once per commit by default. For high-frequency commit workloads, this can be wasteful — a `debounceMs` option may be added later to coalesce multiple commits into a single push (still all individual commits, just a single network round-trip). Out of scope for v1.0.

specs/deferred.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,21 +107,6 @@ When in doubt about whether an entry belongs in `deferred.md`, the litmus test i
107107
- **What:** `infer` generates a starter `[gitsheet.schema]` from existing records; `migrate-config` converts pre-v1.0 `[gitsheet.fields]` configs.
108108
- **Why deferred:** Validation-config tooling that supplements #130's library work. Library APIs exist; CLI surface follows.
109109

110-
### `Sheet.query({ signal })` — AbortSignal — [#154](https://github.com/JarvusInnovations/gitsheets/issues/154)
111-
112-
- **What:** Cancel a streaming query via AbortSignal.
113-
- **Why deferred:** Async generators naturally support `break`; the AbortSignal pattern is sugar for upstream HTTP integrations. Documented in `api/conventions.md`; not implemented in the v1.0 surface.
114-
115-
### PushDaemon: detect non-fast-forward rejection and stop retrying — [#156](https://github.com/JarvusInnovations/gitsheets/issues/156)
116-
117-
- **What:** Inspect git stderr; on a non-fast-forward push rejection, emit a single `error` event with a structured reason and stop retrying that batch (the spec at `behaviors/push-sync.md` requires this, but the v1.0 implementation retries every failure).
118-
- **Why deferred:** The substrate ships with the simpler retry-all semantics; differentiating error classes is a meaningful behavior change that wants its own review.
119-
120-
### PushDaemon: startup diff to push pre-existing local commits — [#157](https://github.com/JarvusInnovations/gitsheets/issues/157)
121-
122-
- **What:** On `startPushDaemon`, diff `<remote>/<branch>..<branch>` and queue any commits ahead of the remote, so a restarted daemon catches up. Spec at `behaviors/push-sync.md` requires this.
123-
- **Why deferred:** Substrate v1.0 only queues commits surfaced via `notifyCommit` after the daemon starts. The startup-diff path needs `git fetch` orchestration + careful error handling for an unreachable remote.
124-
125110
## Dropped (no plan)
126111

127112
### `backend/server.js` HTTP server

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type {
2020
IndexKeyFn,
2121
DefineIndexOptions,
2222
QueryFilter,
23+
QueryOptions,
2324
} from './sheet.js';
2425

2526
export { mergePatch } from './patch.js';

0 commit comments

Comments
 (0)