Skip to content

[API] Redesign client-side snapshot state model #64154

Description

Context

The API currently exposes several overlapping ways to describe state changes:

  • api.updateSnapshot({ fileChanges }) applies caller-reported changes to the API client's latest snapshot.
  • api.createProgram(..., oldProgram, fileChanges) derives a program from the snapshot that owns oldProgram.
  • Add optional VFS parameters to updateSnapshot #64115 adds snapshot.update({ fileSystem }), which layers a virtual filesystem over a snapshot.
  • The LSP-connected API adopts the language server's canonical state through an empty updateSnapshot() call.

These operations grew from different requirements and do not form one coherent state-transition model. In particular:

  • A standalone API client does not inherently have a meaningful "latest" snapshot. Only an LSP session has canonical current state. Currently, if you call api.createProgram without passing an oldProgram, the behavior implicitly depends on what's in the "latest snapshot." It looks totally unexpected that you could get different results at different times because of intermediate calls.
  • fileChanges describes out-of-band mutations rather than performing a state change. It is redundant for in-band virtual filesystem updates that you can do with Add optional VFS parameters to updateSnapshot #64115 and awkward as the default way to obtain TS 6-style incremental behavior.
  • createProgram taking an oldProgram as the source for the base snapshot to clone from means you can't have a sequence like create a program (S1), perform a file system update (S2), then update the program from S1 while keeping the file system changes from S2. On the other hand, allowing createProgram to be based off of an arbitrary snapshot while also taking an arbitrary oldProgram means the server doesn't know the relationship between the base snapshot and the snapshot that produced oldProgram, so oldProgram could never be reused.
  • createProgram currently consumes the one inferred-project slot, preventing a snapshot from containing multiple independently API-created programs. (Having multiple programs in a single snapshot can be desirable if they overlap in source files, since binder symbols have referential equality within the same snapshot.)

Redesign

Clarifying the initial state and removing "latest" state

First, I want to replace api.updateSnapshot with two different methods that make it a little more clear what you're getting:

// Only available in LSP-connected API!
// Gets the canonical state of the LSP server:
const s = api.getLatestSnapshot();
// Maybe even name it `getLatestLSPSnapshot()`?

// Available in both LSP mode and standalone mode:
const s = api.getEmptySnapshot();

The API client will have no concept of a "latest snapshot"; you can get the LSP server's latest snapshot if you're connected to the LSP.

Updating a snapshot

Any snapshot can be updated with changes passed to snapshot.update(), and everything you can do to clone a snapshot with changes should be expressible through that API, including creating/updating/removing a program, opening/closing a tsconfig project, applying virtual file system overlays, etc.:

const s0 = api.getEmptySnapshot();

const s1 = s0.update({
  fileSystem: /* ... */,
  createPrograms: [{ rootFiles, options }],
  openProjects: [pathToTsconfig],
});

getEmptySnapshot (and getLatestSnapshot) can also take a set of initial changes to incorporate (there's no reason to round-trip to get an empty snapshot if you're going to add to it in another request right afterwards). Maybe we should name that method getInitialSnapshot or something, since it's confusing to request an empty snapshot with stuff in it:

const s0 = api.getEmptySnapshot().update({ /* ... */ });
// better written as:
const s0 = api.getInitialSnapshot({ /* ... */ });

Or perhaps api.createSnapshot() encodes the meaning well enough whether or not it has arguments:

const s0 = api.createSnapshot(); // empty, not a ton of reason to do this

const s0 = api.createSnapshot({ createPrograms: [/* ... */] });

File system updates shouldn't automatically refresh affected projects/programs; we need to allow the API client to specify what it needs:

const s0 = api.getInitialSnapshot({ createPrograms: [{ rootFiles, options }] });

// No program update performed, but program is marked dirty if affected
const s1 = s0.update({ fileSystem: /* ... */ });
let program = s1.programs[0];
program.dirty; // true

// Updates the program if dirty
const s2 = s1.update({ ensurePrograms: [program.id] });
program = s2.programs[0];
program.dirty; // false

(We may want to have createProgram pass a program id and look it up from a map rather than autogenerating one and accessing programs in an array.)

This solves the problem of combining efficient updates of existing programs with other snapshot changes without introducing the possibility of grafting a program from one snapshot into an unrelated one with unpredictable results. (It loses the ability to “move” a tsconfig project’s program into an isolated snapshot, but that was sort of a side effect of the current design rather than a use case we were designing for.)

Convenience APIs

One motivation of #63950 was to hide snapshot management for API users who only need to create one program, or multiple disconnected programs, and never need to update them over time. api.createProgram should still exist, but it can be sugar over the lower-level snapshot update API:

const program = api.createProgram({ rootFiles, options });
// Sugar for:
const program = api.getInitialSnapshot({
  createPrograms: [{ rootFiles, options }]
}).programs[0];

I'm undecided whether we should expose a way to update that program without going back out to the snapshot.update API. I think it would be reasonable to say that if you want to do anything that evolves a non-initial snapshot, you should get comfortable using snapshot.update.

Architectural changes

We need to change a few things on the server side to make this happen.

  1. The api.Session really doesn't need to run its snapshot updating work through project.Session unless it's in LSP-connection mode. That will remove the concept of a latest/canonical snapshot from the API server when running in standalone mode. We should extract the parts of project.Session that own global caches, i.e. the interface that snapshot.Clone relies on, into an object that's separate from the project.Session's ownership of a single latest state and reactions to updating that state. In standalone mode, the api.Session can fulfill that interface or own an object implementing that interface rather than owning a full project.Session.
  2. We need to use a different storage mechanism for API-created programs than the single inferred project of project.ProjectCollection. Probably a separate map of projects, leaving both configured and inferred projects alone. (This is needed both to support multiple API-created program in the same snapshot as well as adding a single API-created program to an LSP snapshot where there may already be an inferred project.)

These are both moderately sized refactors, but I think they'll actually simplify the mental model, and everything else should fall cleanly out of these moves.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions