oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "gorm.io/gorm"
11
12 "amadan.net/oficina/memoria/internal/memoria/chunk"
13 "amadan.net/oficina/memoria/internal/memoria/seal"
14)
15
16// Run states.
17const (
18 RunEnumerating = "enumerating"
19 RunFetching = "fetching"
20 RunDone = "done"
21 RunFailed = "failed"
22 RunInterrupted = "interrupted"
23)
24
25// Ledger row states.
26const (
27 PagePending = "pending"
28 PageFetched = "fetched"
29 PageEmpty = "empty"
30 PageFailed = "failed"
31)
32
33// heartbeatStale is how long a run may go without a heartbeat before a
34// LIVE process may reclaim it. A run killed by the platform sleeping is
35// reclaimed at boot instead and does not wait for this.
36const heartbeatStale = 10 * time.Minute
37
38// ErrSyncInFlight is returned when a run is already active.
39var ErrSyncInFlight = errors.New("a sync is already running")
40
41// ErrNoConnector is returned when nothing is configured.
42var ErrNoConnector = errors.New("no connector is configured")
43
44// SaveConnector stores or replaces a connector's credential.
45//
46// Rotation is replacing the row. There is no key history: a replaced
47// token has no remaining use, and keeping one would be a credential
48// retained for no reason.
49func (s *Store) SaveConnector(ctx context.Context, kind, token, workspace string) error {
50 sealed, err := s.sealer.Seal(seal.PurposeCredential, []byte(token))
51 if err != nil {
52 return err
53 }
54 print, err := s.sealer.Fingerprint(seal.PurposeCredential, []byte("workspace\x00"+workspace))
55 if err != nil {
56 return err
57 }
58 now := time.Now().UTC()
59 return s.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
60 var existing Connector
61 res := tx.Where("kind = ?", kind).First(&existing)
62 if res.Error == nil {
63 // An explicit allowlist, never a bound request body.
64 return tx.Model(&Connector{}).Where("id = ?", existing.ID).Updates(map[string]any{
65 "sealed_token": sealed,
66 "workspace_print": print,
67 "last_verified": now,
68 }).Error
69 }
70 return tx.Create(&Connector{
71 Kind: kind, SealedToken: sealed, WorkspacePrint: print, LastVerified: &now,
72 }).Error
73 })
74}
75
76// Connector returns a configured connector, without its token.
77func (s *Store) Connector(ctx context.Context, kind string) (*Connector, error) {
78 var c Connector
79 if err := s.db.G.WithContext(ctx).Where("kind = ?", kind).First(&c).Error; err != nil {
80 if errors.Is(err, gorm.ErrRecordNotFound) {
81 return nil, ErrNoConnector
82 }
83 return nil, err
84 }
85 return &c, nil
86}
87
88// ConnectorToken opens a connector's credential. The only place the
89// plaintext token exists outside the notion client.
90func (s *Store) ConnectorToken(ctx context.Context, kind string) (string, error) {
91 c, err := s.Connector(ctx, kind)
92 if err != nil {
93 return "", err
94 }
95 tok, err := s.sealer.Open(seal.PurposeCredential, c.SealedToken)
96 if err != nil {
97 return "", err
98 }
99 return string(tok), nil
100}
101
102// DeleteConnector removes the credential and the ledger.
103//
104// It deletes NO DOCUMENTS. What memoria imported, memoria keeps — the
105// content was published into memoria and memoria owns it, so removing it
106// is a separate act performed in memoria. The confirmation copy says so
107// in those words rather than leaving it to be discovered.
108func (s *Store) DeleteConnector(ctx context.Context, kind string) error {
109 return s.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
110 var c Connector
111 if err := tx.Where("kind = ?", kind).First(&c).Error; err != nil {
112 if errors.Is(err, gorm.ErrRecordNotFound) {
113 return nil
114 }
115 return err
116 }
117 if err := tx.Where("connector_id = ?", c.ID).Delete(&SourcePage{}).Error; err != nil {
118 return err
119 }
120 if err := tx.Where("connector_id = ?", c.ID).Delete(&SyncRun{}).Error; err != nil {
121 return err
122 }
123 return tx.Where("id = ?", c.ID).Delete(&Connector{}).Error
124 })
125}
126
127// StartRun opens a run, or refuses because one is already active.
128//
129// Single-flight is a conditional insert rather than a mutex, so it
130// survives a restart — but "survives a restart" was the bug in an earlier
131// design, not the feature. The run is a goroutine; when the platform
132// sleeps the process it dies without writing anything, leaving the row
133// "fetching" and every later sync refused as already-running, forever, on
134// the NORMAL path. ReclaimRuns fixes that at boot; the heartbeat below
135// covers a run wedged inside a live process.
136func (s *Store) StartRun(ctx context.Context, connectorID int64) (*SyncRun, error) {
137 var run SyncRun
138 err := s.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
139 var active []SyncRun
140 if err := tx.Where("connector_id = ? AND state IN ?", connectorID,
141 []string{RunEnumerating, RunFetching}).Find(&active).Error; err != nil {
142 return err
143 }
144 for _, a := range active {
145 if a.Heartbeat != nil && time.Since(*a.Heartbeat) > heartbeatStale {
146 if err := tx.Model(&SyncRun{}).Where("id = ?", a.ID).Updates(map[string]any{
147 "state": RunInterrupted,
148 "error_code": "interrupted",
149 }).Error; err != nil {
150 return err
151 }
152 continue
153 }
154 return ErrSyncInFlight
155 }
156 now := time.Now().UTC()
157 run = SyncRun{
158 ConnectorID: connectorID, State: RunEnumerating,
159 Total: -1, StartedAt: now, Heartbeat: &now,
160 }
161 return tx.Create(&run).Error
162 })
163 if err != nil {
164 return nil, err
165 }
166 return &run, nil
167}
168
169// ReclaimRuns marks every run that was in flight as interrupted.
170//
171// Called at boot, and sound by construction rather than by timeout: a
172// process that has just started cannot be running a sync, so anything
173// still marked in flight was killed by the platform.
174func (s *Store) ReclaimRuns(ctx context.Context) (int, error) {
175 res := s.db.G.WithContext(ctx).Model(&SyncRun{}).
176 Where("state IN ?", []string{RunEnumerating, RunFetching}).
177 Updates(map[string]any{"state": RunInterrupted, "error_code": "interrupted"})
178 return int(res.RowsAffected), res.Error
179}
180
181// LatestRun returns the most recent run for a connector.
182func (s *Store) LatestRun(ctx context.Context, connectorID int64) (*SyncRun, error) {
183 var run SyncRun
184 err := s.db.G.WithContext(ctx).Where("connector_id = ?", connectorID).
185 Order("id desc").First(&run).Error
186 if errors.Is(err, gorm.ErrRecordNotFound) {
187 return nil, nil
188 }
189 if err != nil {
190 return nil, err
191 }
192 return &run, nil
193}
194
195// UpdateRun applies an allowlisted patch to a run.
196func (s *Store) UpdateRun(ctx context.Context, runID int64, fields map[string]any) error {
197 allowed := map[string]bool{
198 "state": true, "roots": true, "total": true, "fetched": true,
199 "skipped": true, "empty": true, "failed_pages": true,
200 "unread_blocks": true, "error_code": true, "heartbeat": true,
201 "finished_at": true,
202 }
203 patch := make(map[string]any, len(fields))
204 for k, v := range fields {
205 if !allowed[k] {
206 return fmt.Errorf("update run: field %q is not allowlisted", k)
207 }
208 patch[k] = v
209 }
210 if len(patch) == 0 {
211 return nil
212 }
213 return s.db.G.WithContext(ctx).Model(&SyncRun{}).Where("id = ?", runID).Updates(patch).Error
214}
215
216// Beat records that a run is still alive.
217func (s *Store) Beat(ctx context.Context, runID int64) error {
218 now := time.Now().UTC()
219 return s.UpdateRun(ctx, runID, map[string]any{"heartbeat": now})
220}
221
222// LedgerEntry is one page as the sync loop needs it: the coordinate
223// opened, and enough state to decide whether it needs work.
224type LedgerEntry struct {
225 ID int64
226 PageID string
227 Path []string
228 Title string
229 SeenEdited string
230 IngestedEdited string
231 PathPrint string
232 State string
233 DocumentID int64
234}
235
236// SeeSourcePage records a page the enumeration reached.
237//
238// Upserts on (connector, page print) and writes what enumeration knows:
239// the coordinate, the ancestry, the edit time it SAW. It never touches
240// IngestedEdited — that column belongs to the fetch phase, and collapsing
241// the two into one makes the comparison compare a value against itself
242// and skip every edit.
243func (s *Store) SeeSourcePage(ctx context.Context, connectorID int64, pageID, title string, path []string, edited string) error {
244 print, err := s.sealer.Fingerprint(seal.PurposeCoordinate, []byte("page\x00"+pageID))
245 if err != nil {
246 return err
247 }
248 sealedID, err := s.sealer.Seal(seal.PurposeCoordinate, []byte(pageID))
249 if err != nil {
250 return err
251 }
252 pathStr := chunk.Chunk{Path: path}.PathString()
253 // Through the shared helper, never inline. Two places computing this
254 // fingerprint differently would make every comparison false and
255 // re-ingest the whole workspace on every sync — or make it always
256 // true and never re-ingest anything. Both fail quietly.
257 pathPrint, err := s.pathPrint(path, title)
258 if err != nil {
259 return err
260 }
261 var sealedPath []byte
262 if pathStr != "" {
263 if sealedPath, err = s.sealer.Seal(seal.PurposePath, []byte(pathStr)); err != nil {
264 return err
265 }
266 }
267 now := time.Now().UTC()
268
269 return s.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
270 var row SourcePage
271 res := tx.Where("connector_id = ? AND page_print = ?", connectorID, print).First(&row)
272 if res.Error == nil {
273 return tx.Model(&SourcePage{}).Where("id = ?", row.ID).Updates(map[string]any{
274 "sealed_page_id": sealedID,
275 "sealed_path": sealedPath,
276 "path_print": pathPrint,
277 "seen_edited": edited,
278 "last_seen": now,
279 }).Error
280 }
281 return tx.Create(&SourcePage{
282 ConnectorID: connectorID, PagePrint: print, SealedPageID: sealedID,
283 SealedPath: sealedPath, PathPrint: pathPrint, SeenEdited: edited,
284 State: PagePending, LastSeen: now,
285 }).Error
286 })
287}
288
289// PagesNeedingWork returns the ledger rows the fetch phase must visit.
290//
291// Three conditions, and the second is the one an earlier design missed
292// entirely: an ANCESTOR rename does not change a descendant's own edit
293// time, so without comparing the path print a moved or re-parented page
294// keeps stale ancestry forever and nothing upstream ever signals it.
295func (s *Store) PagesNeedingWork(ctx context.Context, connectorID int64) ([]LedgerEntry, error) {
296 var rows []SourcePage
297 if err := s.db.G.WithContext(ctx).
298 Where("connector_id = ?", connectorID).
299 Order("id").Find(&rows).Error; err != nil {
300 return nil, err
301 }
302 out := make([]LedgerEntry, 0, len(rows))
303 for _, r := range rows {
304 if r.State == PageFetched && r.SeenEdited == r.IngestedEdited && r.PathPrint == r.ingestedPathPrint() {
305 continue
306 }
307 id, err := s.sealer.Open(seal.PurposeCoordinate, r.SealedPageID)
308 if err != nil {
309 return nil, fmt.Errorf("open coordinate %d: %w", r.ID, err)
310 }
311 var path []string
312 if len(r.SealedPath) > 0 {
313 p, err := s.sealer.Open(seal.PurposePath, r.SealedPath)
314 if err != nil {
315 return nil, fmt.Errorf("open path %d: %w", r.ID, err)
316 }
317 if s := string(p); s != "" {
318 path = strings.Split(s, " › ")
319 }
320 }
321 out = append(out, LedgerEntry{
322 ID: r.ID, PageID: string(id), Path: path,
323 SeenEdited: r.SeenEdited, IngestedEdited: r.IngestedEdited,
324 PathPrint: r.PathPrint, State: r.State, DocumentID: r.DocumentID,
325 })
326 }
327 return out, nil
328}
329
330// ingestedPathPrint is the path print as of the last successful ingest.
331func (r SourcePage) ingestedPathPrint() string { return r.IngestedPath }
332
333// MarkPage records the outcome of fetching one page.
334func (s *Store) MarkPage(ctx context.Context, id int64, state, errorCode string, documentID int64, edited, pathPrint string) error {
335 return s.db.G.WithContext(ctx).Model(&SourcePage{}).Where("id = ?", id).Updates(map[string]any{
336 "state": state,
337 "error_code": errorCode,
338 "document_id": documentID,
339 "ingested_edited": edited,
340 "ingested_path": pathPrint,
341 }).Error
342}
343
344// CountPages returns how many ledger rows a connector holds, and how many
345// are in each terminal state.
346func (s *Store) CountPages(ctx context.Context, connectorID int64) (total, fetched int, err error) {
347 var t, f int64
348 q := s.db.G.WithContext(ctx).Model(&SourcePage{}).Where("connector_id = ?", connectorID)
349 if err := q.Count(&t).Error; err != nil {
350 return 0, 0, err
351 }
352 // "Imported" means memoria HOLDS a document for this row, which is
353 // what document_id records — not what state the last fetch ended in.
354 //
355 // Counting states instead made a page that imported fine and then
356 // 404'd on a later run drop out of the total, though the document was
357 // still there and still searchable. memoria does not delete on
358 // absence; the count must not imply that it did.
359 if err := s.db.G.WithContext(ctx).Model(&SourcePage{}).
360 Where("connector_id = ? AND document_id > 0", connectorID).Count(&f).Error; err != nil {
361 return 0, 0, err
362 }
363 return int(t), int(f), nil
364}
365