d2ts

repository·main·Indexed 19 days ago

https://github.com/electric-sql/d2ts

A TypeScript implementation of Differential Dataflow for building type-safe, high-performance incremental data pipelines. It enables efficient computations over changing data streams by only recomputing modified parts of the data. Features include a rich set of pipeline operators (map, filter, join, reduce, count), D2QL for SQL-like query structures, and integration with ElectricSQL ShapeStreams for real-time database change processing. Also includes D2Mini, a simplified version for Incremental View Maintenance (IVM) without multi-dimensional versioning.

Tokens
29.8K
Snippets
106
Records
129
Agent score
68%

What's inside d2ts

  1. Overview of D2Mini

    main

    D2Mini is a minimal implementation of the D2TS dataflow graph library. It is a simplified version designed for Incremental View Maintenance (IVM) based on Differential Dataflow, but without the complexities of multi-dimensional versioning.

    While the API is almost identical to @electric-sql/d2ts, D2Mini simplifies the workflow by removing the requirement to:

    1. Specify a version when sending data.
    2. Send a frontier to mark the end of a version.
  2. Use MultiSet to represent changesets

    main

    A MultiSet is a map of values to their multiplicity, used to represent changes (inserts and deletes) to a collection.

    • Inserts: Represented by a multiplicity of 1.
    • Deletes: Represented by a multiplicity of -1.
    • Keyed MultiSets: A common pattern where the value is a tuple of [key, value]. This is essential for joins and grouping operations.

    Example of a keyed MultiSet for comments keyed by userId:

    // MultiSet of new "comments" keyed by userId
    const multiSet = new MultiSet<[string, Comment]>([
      [['321', { id: '1', text: 'Hello, world!', userId: '321' }], 1],
      [['123', { id: '2', text: 'Hello, world!', userId: '123' }], 1],
    ])
    
    // Representing an insert and a delete
    const changes = new MultiSet<[string, Comment]>([
      [['321', { id: '1', text: 'Hello, world!', userId: '321' }], 1], // Insert
      [['123', { id: '2', text: 'Hello, world!', userId: '123' }], -1], // Delete
    ])
  3. How the ElectricSQL and D2TS integration works

    main

    The integration connects real-time database changes to an incremental computation graph using the following pattern:

    1. ElectricSQL ShapeStreams: These provide real-time streams of changes emitted from the database.
    2. MultiShapeStream: Used to consume multiple shapes (one for each table, e.g., users, issues, comments) from a single Electric instance.
    3. electricStreamToD2Input: A helper function used to bridge ElectricSQL streams into D2TS inputs.
    4. D2TS Graph: The core engine that performs incremental transformations such as joins (e.g., joining issues with creators) and aggregations (e.g., counting comments per issue).
    5. LSN-based Processing: Data changes are processed using PostgreSQL Log Sequence Numbers (LSNs), which are passed to D2TS as the data's "version" to ensure consistency.
  4. Core data structures in D2TS

    main

    D2TS relies on several specialized data structures to manage differential dataflow operations:

    • MultiSet: Used to represent collections where elements can have multiplicities.
    • Version: Manages partially ordered versions to track data evolution.
    • Antichain: Used for managing frontiers within the dataflow.
    • Index: Responsible for storing versioned operator state.
  5. Operator types in D2TS

    main

    D2TS provides two categories of operators:

    1. Base operators: Standard implementations found in src/operators/.
    2. SQLite variants: Specialized operators designed for persistence to SQLite, found in src/sqlite/operators/.
  6. D2QL Query Structure

    main

    D2QL queries are defined as TypeScript objects. The structure mimics SQL syntax using specific keys for selection, source tables, filtering, and joins.

    Key components include:

    • select: An array containing column references (e.g., '@column') or objects for aliasing (e.g., { alias: '@column' }).
    • from: The name of the primary table.
    • where: An array representing conditions, typically in the format ['@column', 'operator', 'value'].
    • join: An array of join objects specifying the type (e.g., 'inner', 'left'), the from table, and the on condition.
    const query = {
      select: ['@column1', { alias: '@column2' }],
      from: 'table_name',
      where: ['@column', '=', 'value'],
      join: [{
        type: 'inner',
        from: 'other_table',
        on: ['@table.column', '=', '@other_table.column']
      }]
    };
  7. Handle Versions and Frontiers

    main

    D2TS uses versions to track data state and frontiers to represent the lower bound of future data.

    Versions

    Versions are a lattice of integers. While you can use multidimensional versions, most use cases only require a single integer.

    • Use the v() helper function to create Version objects efficiently. This ensures object reuse for faster equality checks.
    • Single integer versions can be passed directly to sendData and sendFrontier.

    Frontiers (Antichains)

    An Antichain is a set of disjoint versions representing the frontier. You can create one using the Antichain constructor or pass a single integer directly to sendFrontier.

    // Creating a version
    const version = v(1)
    const multiVersion = v([1, 2])
    
    // Sending data with a simple integer version
    input.sendData(1, new MultiSet([[1, 1]]))
    
    // Creating an Antichain (frontier)
    const frontier = new Antichain([v(1), v([2])])
    
    // Sending a frontier with a simple integer
    input.sendFrontier(1)
  8. How D2TS graph execution works

    main

    The execution of a D2TS dataflow graph involves several coordinated components:

    • Graph Management: Handled by src/graph.ts and src/D2.ts.
    • Message Passing: Operators communicate via a message-passing mechanism.
    • Frontier Tracking: The system manages the advancement of frontiers to ensure data consistency across the graph.
  9. Basic Usage of D2Mini

    main

    To use D2Mini, you follow a lifecycle of creating a graph, defining inputs, building a pipeline of operators, finalizing the graph, sending data as changes, and running the computation.

    Core Concepts

    • Graph: The container for the dataflow.
    • Input Stream: The entry point for data into the graph.
    • MultiSet: Data is sent as a MultiSet, which represents a change to the data rather than the state itself. An element with a multiplicity of 1 represents an Insert, while a multiplicity of -1 represents a Delete.
    • Pipeline: A sequence of operators like map, filter, and debug applied to a stream.

    Workflow

    1. Initialize a new D2() graph.
    2. Create an input using graph.newInput<T>().
    3. Chain operators using .pipe().
    4. Call graph.finalize() to lock the graph structure.
    5. Send data via input.sendData(multiSet).
    6. Execute the computation with graph.run().
    import { D2, map, filter, debug, MultiSet } from '@electric-sql/d2ts'
    
    // 1. Create a new D2 graph
    const graph = new D2()
    
    // 2. Create an input stream
    const input = graph.newInput<number>()
    
    // 3. Build a pipeline
    const output = input.pipe(
      map((x) => x + 5),
      filter((x) => x % 2 === 0),
      debug('output'),
    )
    
    // 4. Finalize the pipeline
    graph.finalize()
    
    // 5. Send data (MultiSet represents changes: [value, multiplicity])
    input.sendData(
      new MultiSet([
        [1, 1], // Insert 1
        [2, 1], // Insert 2
        [3, 1], // Insert 3
      ]),
    )
    
    // 6. Process the data
    graph.run()
  10. Use SQLite for persistence and large datasets

    main

    For larger datasets or when persistence is required, several operators support a SQLite backend. You can pass a database wrapper as the final argument to the operator.

    Supported operators with SQLite:

    • consolidate()
    • count()
    • distinct()
    • join()
    • map()
    • reduce()

    Operators will automatically create necessary tables and indexes. It is recommended to use the same database instance for all operators to ensure state is stored in a single location.

    To use better-sqlite3, wrap it with BetterSQLite3Wrapper.

    import Database from 'better-sqlite3'
    // ...
    const sqlite = new Database('./my_database.db')
    const db = new BetterSQLite3Wrapper(sqlite)
    
    const output = input.pipe(consolidate(db))