Skip to content

[1b] Route getDraft and saveDraft through an async repository #431

Closed

Description

@imnasnainaec

Part of Phase 1 (#424) of the data layer plan.

Async repository interface and in-memory implementation over today's format; getDraft/saveDraft route through it; a Node seeding path through it replaces the #407 harness's PAPI seeding.

Done when: the existing unit and e2e suites pass unchanged, a newer build's draft survives an attempted write, and the #407 harness seeds every tier without PAPI.

Depends on: 1a (#430).

Activity

  1. myieye commented on Oct 16, 2026

    @myieye
    Contributor

    A proposal for 1b's interface, to make it easy to also implement in C# for the Harmony store (#462). It doesn't change the plan; it fills in what the plan leaves to 1b. I worked through it with Claude, comparing it against how FW Lite's MiniLcm and Harmony split the work, so take it as a reasonable starting point to poke holes in, not a decision.

    • Pieces of work stay in the host commands (writeGloss, createPhrase, ..., 3d to 3i), each with its own request type. The repository receives their result: a ChangeSet of the records that changed, applied atomically as one commit, one undo step and one change event. Its intent field only names the command.
    • ChangeSet could be explicit: one typed list per kind instead of a polymorphic record list. Same shape as today's TextAnalysis; a plain class in C#.
    • Reads named and typed per kind (getTokenAnalyses(ids), findLinksByToken(tokenRefs), ...), rather than one generic get/find.
    • Only JSON-plain values across the seam: no Map, Set, callbacks or class instances; remote changes arrive as a PAPI event.
    • Optional fields omitted, never null, with 1c ([1c] Add a repository conformance suite #432) checking it.

    Since the plan keeps the interlinear rules in the host, the store doesn't need MiniLcm-style per-intent changes. The exception is a case that merges badly as a record diff between teammates (likely the morpheme list or a phrase's member tokens), which could get its own Harmony change type.

    Interface sketch (TypeScript, not compiled)
    type RecordId = string; // uuid
    type BookCode = string; // 'GEN'
    type TokenRef = string; // 'GEN 1:1:5'
    type SegmentId = string; // 'GEN 1:1'
    
    // Record types are today's, from interlinearizer.d.ts; token and segment links get ids in 1a.
    
    interface ScopeRecord {
      id: RecordId;
      modelVersion: number;
      name?: string;
      description?: string;
      analysisLanguages: string[];
      segmentation?: SegmentationDelta;
      sourceProjectId: string;
      targetProjectId?: string;
      createdAt: string;
      updatedAt: string;
    }
    
    interface Changes<T> { puts: T[]; deletes: RecordId[] }
    
    /** One command's result, applied all-or-nothing. */
    interface ChangeSet {
      id: string; // a retry with the same id applies once
      intent: string; // the command's name
      scope?: ScopeRecord;
      tokenAnalyses?: Changes<TokenAnalysis>;
      tokenLinks?: Changes<TokenAnalysisLink>;
      phraseAnalyses?: Changes<PhraseAnalysis>;
      phraseLinks?: Changes<PhraseAnalysisLink>;
      segmentAnalyses?: Changes<SegmentAnalysis>;
      segmentLinks?: Changes<SegmentAnalysisLink>;
    }
    
    interface ApplyResult { inverse: ChangeSet } // undo
    
    interface Links {
      tokenLinks: TokenAnalysisLink[];
      phraseLinks: PhraseAnalysisLink[];
      segmentLinks: SegmentAnalysisLink[];
    }
    
    interface CatalogQuery {
      kind: 'tokenAnalysis' | 'phraseAnalysis';
      text?: string; // matches form or any gloss
      books?: BookCode[];
      usage: 'any' | 'used' | 'unused';
      sortField: 'form' | 'gloss' | 'usage' | 'updatedAt';
      sortDirection: 'asc' | 'desc';
      offset: number;
      count: number;
    }
    
    interface CatalogRow {
      tokenAnalysis?: TokenAnalysis; // exactly one of these two is set
      phraseAnalysis?: PhraseAnalysis;
      usageCount: number;
      books: BookCode[];
    }
    
    interface Capabilities {
      drafts: boolean; // local: true; shared: false
      readOnly: boolean; // PT9 imports, Observers
      remote: boolean; // has teammates' changes to pull
    }
    
    // Reads promise no order; a command that must pick one record picks by rule (1a).
    interface AnalysisRepository {
      readonly capabilities: Capabilities;
    
      getScope(): Promise<ScopeRecord>;
      getTokenAnalyses(ids: RecordId[]): Promise<TokenAnalysis[]>; // missing ids are omitted
      getPhraseAnalyses(ids: RecordId[]): Promise<PhraseAnalysis[]>;
      getSegmentAnalyses(ids: RecordId[]): Promise<SegmentAnalysis[]>;
    
      /** Token links on these tokens, plus phrase links touching any of them. */
      findLinksByToken(tokenRefs: TokenRef[]): Promise<Pick<Links, 'tokenLinks' | 'phraseLinks'>>;
      findLinksBySegment(segmentIds: SegmentId[]): Promise<SegmentAnalysisLink[]>;
      findLinksToAnalyses(analysisIds: RecordId[]): Promise<Links>;
      findLinksInBook(book: BookCode, statuses?: AssignmentStatus[]): Promise<Links>;
      findTokenAnalysesByForm(normalizedForms: string[]): Promise<TokenAnalysis[]>;
      findPhraseAnalysesByForm(normalizedForms: string[]): Promise<PhraseAnalysis[]>;
    
      countUsages(analysisIds: RecordId[]): Promise<Record<RecordId, number>>;
      getCatalogPage(query: CatalogQuery): Promise<{ rows: CatalogRow[]; total: number }>;
    
      /** Refused when read-only or when a record carries a newer model version. */
      apply(changes: ChangeSet): Promise<ApplyResult>;
    
      /** Teammates' changes since the last pull, as one ChangeSet. */
      pullRemoteChanges(): Promise<ChangeSet>;
    
      /** Temporary until 4c. */
      exportAll(): Promise<TextAnalysis>;
      replaceAll(analysis: TextAnalysis, intent: string): Promise<ApplyResult>;
    
      close(): Promise<void>;
    }
    
    // Remote changes ready to pull: PAPI network event, payload { scopeId }.
    The same in C# (excerpt)
    public record Changes<T>(IReadOnlyList<T> Puts, IReadOnlyList<Guid> Deletes);
    
    public record ChangeSet(
        string Id,
        string Intent,
        ScopeRecord? Scope,
        Changes<TokenAnalysis>? TokenAnalyses,
        Changes<TokenAnalysisLink>? TokenLinks,
        Changes<PhraseAnalysis>? PhraseAnalyses,
        Changes<PhraseAnalysisLink>? PhraseLinks,
        Changes<SegmentAnalysis>? SegmentAnalyses,
        Changes<SegmentAnalysisLink>? SegmentLinks);
    
    public interface IAnalysisStore
    {
        Task<IReadOnlyList<TokenAnalysis>> GetTokenAnalyses(IReadOnlyList<Guid> ids);
        Task<Links> FindLinksToAnalyses(IReadOnlyList<Guid> analysisIds);
        Task<Dictionary<Guid, int>> CountUsages(IReadOnlyList<Guid> analysisIds);
        Task<ApplyResult> Apply(ChangeSet changes);
        // ...the rest mirror the TypeScript one-to-one
    }
    // Serialize with camelCase names, string enums and JsonIgnoreCondition.WhenWritingNull.
  2. self-assigned this
    on Oct 16, 2026
  3. self-assigned this
    on Oct 16, 2026
  4. imnasnainaec commented on Oct 16, 2026

    @imnasnainaec
    Author
  5. removed their assignment
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions