You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
2. Waits for the in-flight push to complete or fail
163
167
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
165
169
5. Resolves once the daemon is fully idle
166
170
167
171
For Kubernetes / Docker, set `terminationGracePeriodSeconds` to at least `timeoutMs` + a safety margin so the orchestrator doesn't SIGKILL mid-drain.
168
172
169
173
## Non-fast-forward rejections
170
174
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:
172
176
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.
174
181
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.
176
183
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.
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.
180
202
181
203
## A complete production setup
182
204
@@ -202,8 +224,11 @@ if (process.env.PUSH_REMOTE) {
@@ -67,17 +67,19 @@ Every error thrown by gitsheets extends `GitsheetsError` and carries a stable `c
67
67
68
68
## Cancellation
69
69
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.
71
71
72
72
```typescript
73
73
const controller =newAbortController();
74
74
forawait (const record ofsheet.query({}, { signal: controller.signal })) {
75
75
// ...
76
-
if (someCondition) controller.abort();
76
+
if (someCondition) controller.abort();// optional reason arg → signal.reason
77
77
}
78
78
```
79
79
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).
Copy file name to clipboardExpand all lines: specs/api/sheet.md
+5-5Lines changed: 5 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -25,15 +25,15 @@ for await (const user of sheet.query({ accountLevel: 'staff' })) {
25
25
-`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.
26
26
- 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)).
27
27
- Order of results: filesystem order within each tree level. Not guaranteed stable across implementation changes — sort in consumer code if order matters.
-`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).
29
29
30
-
### `sheet.queryFirst(filter?)`
30
+
### `sheet.queryFirst(filter?, opts?)`
31
31
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`.
33
33
34
-
### `sheet.queryAll(filter?)`
34
+
### `sheet.queryAll(filter?, opts?)`
35
35
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`.
Copy file name to clipboardExpand all lines: specs/behaviors/push-sync.md
+27-6Lines changed: 27 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -36,10 +36,12 @@ If a consumer needs to incorporate changes from elsewhere (a manual edit, a sepa
36
36
37
37
The underlying operation is `git push <remote> <branch>` with no `--force`. **Regular fast-forward only.**
38
38
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.
40
40
41
41
The daemon does *not* attempt to merge or rebase. The local commit history is the source of truth.
42
42
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
+
43
45
## Backoff
44
46
45
47
Default: exponential, base 1 second, multiplier 2, cap 1 hour.
@@ -72,16 +74,20 @@ Or accept the default with `backoff: 'exponential'`.
-`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.
83
87
-`stopped` — emitted after `daemon.stop()` completes draining.
84
88
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
+
85
91
Consumers building observability surfaces typically attach to `push`, `retry`, `error` for Prometheus / structured-logging fan-out.
86
92
87
93
## Status
@@ -91,7 +97,12 @@ daemon.status();
91
97
// → {
92
98
// running: boolean,
93
99
// 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,
95
106
// pendingCommits: number,
96
107
// currentBackoffMs: number | null, // current delay, if a retry is scheduled
97
108
// 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
136
147
137
148
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.
138
149
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
+
139
160
## Push frequency
140
161
141
162
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.
### `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.
0 commit comments