| index 10bcea4..bc7c7c6 100644 |
| --- a/internal/core/core.go |
| +++ b/internal/core/core.go |
| @@ -114,11 +114,12 @@ const defaultGuestCallTimeout = 1 * time.Second |
| // here — this package does not do either yet. |
| // |
| // Until then, this package does the safe thing available without that |
| -// storage wired up: once a document's log is full, Apply refuses to accept further updates |
| -// at all (ErrUpdateLogFull), rather than either growing the log without |
| -// bound or silently dropping old entries — dropping old entries would make |
| -// a later rebuild replay an incomplete history and produce silently wrong |
| -// content, which is worse than refusing. |
| +// storage wired up: once a document's log is full, Apply refuses to |
| +// accept further updates at all (ErrUpdateLogFull), rather than either |
| +// growing the log without bound or silently dropping old entries — |
| +// dropping old entries would make a later rebuild replay an incomplete |
| +// history and produce silently wrong content, which is worse than |
| +// refusing. |
| // |
| // How reachable this cap actually is, for whoever writes the |
| // snapshot/compaction plan: the docs app persists one doc_updates row |
| @@ -145,7 +146,7 @@ const MaxRetainedUpdates = maxRetainedUpdates |
| // update log has reached maxRetainedUpdates. See that constant's doc |
| // comment: this is a deliberate, visible failure standing in for |
| // compaction support this ABI doesn't have yet, not a bug. |
| -var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; document needs compaction (unsupported under ABI v1) before it can accept further updates") |
| +var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; document needs compaction (not yet wired up on the host side) before it can accept further updates") |
| |
| // Option configures a Runtime. See WithGuestCallTimeout. |
| type Option func(*runtimeOptions) |
| @@ -575,7 +576,93 @@ func (h *Handle) JSON(ctx context.Context) (string, error) { |
| return h.read(ctx, "json") |
| } |
| |
| -// read is the shared body of Text, HTML and JSON: call the named |
| +// Snapshot returns the document's entire state as one Yjs update — what |
| +// doc_snapshots stores so a later replay can start from it instead of |
| +// from the whole log (the snapshots design, D1). |
| +// |
| +// Bytes rather than a string, but it rides the same len/read machinery |
| +// as Text/HTML/JSON, including their trap-and-retry: a Go string is a |
| +// byte sequence and nothing here interprets it, so "snapshot" is simply |
| +// a fourth renderer name to readOn. |
| +func (h *Handle) Snapshot(ctx context.Context) ([]byte, error) { |
| + s, err := h.read(ctx, "snapshot") |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []byte(s), nil |
| +} |
| + |
| +// verifySnapshotOn allocates candidate into mod's linear memory, calls |
| +// docs_core_verify_snapshot against docHandle, frees the buffer, and |
| +// returns the raw i32 return code. Structured exactly like applyOn — |
| +// docs_core_verify_snapshot is the only other export besides |
| +// docs_core_apply that takes a caller-supplied byte buffer into guest |
| +// memory, so the alloc/write/call/free dance and its error handling are |
| +// copied from there rather than reinvented. |
| +func verifySnapshotOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, candidate []byte) (int32, error) { |
| + ptr, err := allocIn(ctx, timeout, mod, uint64(len(candidate))) |
| + if err != nil { |
| + return 0, fmt.Errorf("alloc: %w", err) |
| + } |
| + defer freeIn(ctx, timeout, mod, ptr, uint64(len(candidate))) |
| + |
| + if !mod.Memory().Write(uint32(ptr), candidate) { |
| + return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(candidate), ptr) |
| + } |
| + |
| + rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_verify_snapshot"), docHandle, ptr, uint64(len(candidate))) |
| + if err != nil { |
| + return 0, fmt.Errorf("verify_snapshot trapped or timed out: %w", err) |
| + } |
| + return int32(rc[0]), nil |
| +} |
| + |
| +// VerifySnapshot asks the core whether candidate, replayed into an empty |
| +// document, is this document — state vector, delete set and structural |
| +// render (the snapshots design, D5). It is a smoke test rather than a |
| +// proof, and it cannot detect a snapshot taken from a stale state; see |
| +// core/src/snapshot.rs for both limits. |
| +// |
| +// docs_core_verify_snapshot's four outcomes map as: 0 (the candidate is |
| +// this document) -> true, nil; 1 (it is not) -> false, nil; ERR_DECODE |
| +// (candidate is not a decodable update at all) -> false, nil — bad bytes |
| +// are a "no" answer, not a failure of this call; anything else (a trap, |
| +// a deadline exceeded, ERR_NO_DOC, ERR_INVALID_LENGTH, or an |
| +// unrecognised code) -> false, error. |
| +// |
| +// A trap or timeout is handled exactly as in Apply: the instance is |
| +// discarded and rebuilt from every update applied here so far (the |
| +// document itself survives), and the returned error is a *RecoveredError |
| +// so a caller can tell that apart from a rebuild that itself failed. |
| +// Unlike Apply, there is no update to omit from replay — this call never |
| +// mutates h's document at all, only a throwaway one inside the guest. |
| +func (h *Handle) VerifySnapshot(ctx context.Context, candidate []byte) (bool, error) { |
| + const errDecode = -2 |
| + |
| + code, err := verifySnapshotOn(ctx, h.rt.callTimeout, h.mod, h.handle, candidate) |
| + if err != nil { |
| + if isFatal(err) { |
| + if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| + return false, errors.Join(err, rebuildErr) |
| + } |
| + return false, &RecoveredError{err} |
| + } |
| + return false, err |
| + } |
| + |
| + switch code { |
| + case 0: |
| + return true, nil |
| + case 1: |
| + return false, nil |
| + case errDecode: |
| + return false, nil |
| + default: |
| + return false, fmt.Errorf("verify_snapshot failed: code %d", code) |
| + } |
| +} |
| + |
| +// read is the shared body of Text, HTML, JSON and Snapshot: call the named |
| // renderer's len/read pair, and if the call itself traps or times out, |
| // discard the instance, rebuild it exactly as Apply does, and retry the |
| // read once against the fresh one. Retrying rather than reporting an |
| @@ -608,10 +695,10 @@ func (h *Handle) read(ctx context.Context, renderer string) (string, error) { |
| |
| // readOn performs one renderer's len-then-read dance against |
| // docs_core_<renderer>_len and docs_core_<renderer>_read. renderer is |
| -// always one of this package's own three literals ("text", "html", |
| -// "json"), never anything derived from a request — the export names it |
| -// builds are a closed set, and a name that does not resolve to an |
| -// exported function surfaces below as a nil api.Function. |
| +// always one of this package's own four literals ("text", "html", |
| +// "json", "snapshot"), never anything derived from a request — the |
| +// export names it builds are a closed set, and a name that does not |
| +// resolve to an exported function surfaces below as a nil api.Function. |
| func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, renderer string) (string, error) { |
| lenFn := mod.ExportedFunction("docs_core_" + renderer + "_len") |
| readFn := mod.ExportedFunction("docs_core_" + renderer + "_read") |