Add pagination and iteration to tabular storage - #210
Conversation
Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
…eter signatures Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
|
@copilot fix build and test errors |
…sitoryTests function Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
Fixed the build error. The iteration method tests were placed outside the |
There was a problem hiding this comment.
Pull request overview
This pull request adds pagination and iteration capabilities to the tabular storage system. It introduces three new methods to the ITabularStorage interface: getBulk(offset, limit) for fetching pages of records, and two async generators records(pageSize?) and pages(pageSize?) for iterating through records one-by-one or page-by-page respectively.
Changes:
- Added
getBulk,records, andpagesmethods to the tabular storage interface and all 8 implementations - Implemented concrete
records()andpages()generators inBaseTabularStoragethat usegetBulk()internally - Added comprehensive test coverage for all three new methods
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/storage/src/tabular/ITabularStorage.ts | Added interface definitions for getBulk, records, and pages methods |
| packages/storage/src/tabular/BaseTabularStorage.ts | Added abstract getBulk method and concrete records/pages generator implementations |
| packages/storage/src/tabular/InMemoryTabularStorage.ts | Implemented getBulk using array slicing on in-memory values |
| packages/storage/src/tabular/SqliteTabularStorage.ts | Implemented getBulk using SQL LIMIT/OFFSET with proper value conversion |
| packages/storage/src/tabular/PostgresTabularStorage.ts | Implemented getBulk using PostgreSQL LIMIT/OFFSET with proper value conversion |
| packages/storage/src/tabular/SupabaseTabularStorage.ts | Implemented getBulk using Supabase .range() API with proper value conversion |
| packages/storage/src/tabular/IndexedDbTabularStorage.ts | Implemented getBulk using IndexedDB cursor with advance() for offset handling |
| packages/storage/src/tabular/FsFolderTabularStorage.ts | Implemented getBulk by slicing file list and reading JSON files |
| packages/storage/src/tabular/CachedTabularStorage.ts | Implemented getBulk by delegating to cache storage |
| packages/storage/src/tabular/SharedInMemoryTabularStorage.ts | Implemented getBulk by delegating to internal in-memory repository |
| packages/test/src/test/storage-tabular/genericTabularRepositoryTests.ts | Added comprehensive tests for getBulk, records, and pages methods covering various edge cases |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if (!data || data.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const entities: Entity[] = []; | ||
| for (const row of data) { | ||
| const entity = {} as Entity; | ||
| for (const [column, value] of Object.entries(row)) { | ||
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | ||
| } | ||
| entities.push(entity); | ||
| } | ||
|
|
||
| return entities; |
There was a problem hiding this comment.
Inconsistent pattern for value conversion compared to getAll() method. The getAll() method mutates rows in-place (lines 593-598), while this method creates new entity objects. Consider using the same pattern as getAll() for consistency:
for (const row of data) {
for (const key in this.schema.properties) {
row[key] = this.sqlToJsValue(key, row[key]);
}
}
return data as Entity[];
| if (!data || data.length === 0) { | |
| return undefined; | |
| } | |
| const entities: Entity[] = []; | |
| for (const row of data) { | |
| const entity = {} as Entity; | |
| for (const [column, value] of Object.entries(row)) { | |
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | |
| } | |
| entities.push(entity); | |
| } | |
| return entities; | |
| if (!data || data.length === 0) { | |
| return undefined; | |
| } | |
| for (const row of data) { | |
| for (const key in this.schema.properties) { | |
| (row as any)[key] = this.sqlToJsValue(key, (row as any)[key]); | |
| } | |
| } | |
| return data as Entity[]; |
|
|
||
| if (!rows || rows.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const entities: Entity[] = []; | ||
| for (const row of rows) { | ||
| const entity = {} as Entity; | ||
| for (const [column, value] of Object.entries(row)) { | ||
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | ||
| } | ||
| entities.push(entity); | ||
| } | ||
|
|
||
| return entities; |
There was a problem hiding this comment.
Inconsistent pattern for value conversion compared to getAll() method. The getAll() method mutates rows in-place (lines 774-779), while this method creates new entity objects. Consider using the same pattern as getAll() for consistency:
for (const row of rows) {
for (const k in this.schema.properties) {
row[k] = this.sqlToJsValue(k, row[k]);
}
}
return rows;
| if (!rows || rows.length === 0) { | |
| return undefined; | |
| } | |
| const entities: Entity[] = []; | |
| for (const row of rows) { | |
| const entity = {} as Entity; | |
| for (const [column, value] of Object.entries(row)) { | |
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | |
| } | |
| entities.push(entity); | |
| } | |
| return entities; | |
| if (!rows || rows.length === 0) { | |
| return undefined; | |
| } | |
| for (const row of rows) { | |
| for (const k in this.schema.properties) { | |
| (row as any)[k] = this.sqlToJsValue(k, (row as any)[k] as ValueOptionType); | |
| } | |
| } | |
| return rows as Entity[]; |
| */ | ||
| async getBulk(offset: number, limit: number): Promise<Entity[] | undefined> { | ||
| const db = this.db; | ||
| const result = await db.query(`SELECT * FROM "${this.table}" LIMIT $1 OFFSET $2`, [limit, offset]); |
There was a problem hiding this comment.
Missing ORDER BY clause in the SQL query. Without an explicit ORDER BY, the order of returned records is not guaranteed to be consistent across multiple calls or database restarts. This can lead to:
- Inconsistent pagination (same record appearing multiple times or being skipped)
- Non-deterministic behavior in the
records()andpages()iterators
Consider adding an ORDER BY clause using the primary key columns to ensure consistent ordering:
SELECT * FROM "${this.table}" ORDER BY ${primaryKeyColumns.join(', ')} LIMIT $1 OFFSET $2This is especially important for pagination, where users expect the same page to return the same results on subsequent calls.
| const result = await db.query(`SELECT * FROM "${this.table}" LIMIT $1 OFFSET $2`, [limit, offset]); | |
| const orderByClause = this.primaryKeyColumns.map((col) => `"${col}"`).join(", "); | |
| const result = await db.query( | |
| `SELECT * FROM "${this.table}" ORDER BY ${orderByClause} LIMIT $1 OFFSET $2`, | |
| [limit, offset] | |
| ); |
| * @returns Array of entities or undefined if no records found | ||
| */ | ||
| async getBulk(offset: number, limit: number): Promise<Entity[] | undefined> { | ||
| const all = Array.from(this.values.values()); |
There was a problem hiding this comment.
The order of values from Map.values() is based on insertion order, but there's no guarantee about the order after deletions and re-insertions. For consistent pagination behavior across the codebase, consider sorting the results by primary key before slicing:
const all = Array.from(this.values.values());
// Sort by primary key to ensure consistent ordering
all.sort((a, b) => {
for (const key of this.primaryKeyNames) {
const aVal = (a as any)[key];
const bVal = (b as any)[key];
if (aVal < bVal) return -1;
if (aVal > bVal) return 1;
}
return 0;
});
const page = all.slice(offset, offset + limit);
return page.length > 0 ? page : undefined;This ensures pagination returns results in a deterministic order, matching the behavior expected from the SQL-based implementations.
| const all = Array.from(this.values.values()); | |
| const all = Array.from(this.values.values()); | |
| // Ensure deterministic ordering by sorting by primary key(s) before pagination | |
| all.sort((a, b) => { | |
| for (const key of this.primaryKeyNames) { | |
| const aVal = (a as any)[key]; | |
| const bVal = (b as any)[key]; | |
| if (aVal < bVal) return -1; | |
| if (aVal > bVal) return 1; | |
| } | |
| return 0; | |
| }); |
|
|
||
| if (!result.rows || result.rows.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const entities: Entity[] = []; | ||
| for (const row of result.rows) { | ||
| const entity = {} as Entity; | ||
| for (const [column, value] of Object.entries(row)) { | ||
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | ||
| } | ||
| entities.push(entity); | ||
| } | ||
|
|
||
| return entities; |
There was a problem hiding this comment.
Inconsistent pattern for value conversion compared to getAll() method. The getAll() method mutates rows in-place (lines 806-812), while this method creates new entity objects. Consider using the same pattern as getAll() for consistency:
for (const row of result.rows) {
for (const key in this.schema.properties) {
row[key] = this.sqlToJsValue(key, row[key]);
}
}
return result.rows;
| if (!result.rows || result.rows.length === 0) { | |
| return undefined; | |
| } | |
| const entities: Entity[] = []; | |
| for (const row of result.rows) { | |
| const entity = {} as Entity; | |
| for (const [column, value] of Object.entries(row)) { | |
| (entity as any)[column] = this.sqlToJsValue(column, value as ValueOptionType); | |
| } | |
| entities.push(entity); | |
| } | |
| return entities; | |
| if (!result.rows || result.rows.length === 0) { | |
| return undefined; | |
| } | |
| for (const row of result.rows) { | |
| for (const key in this.schema.properties) { | |
| (row as any)[key] = this.sqlToJsValue(key, (row as any)[key]); | |
| } | |
| } | |
| return result.rows as Entity[]; |
| async getBulk(offset: number, limit: number): Promise<Entity[] | undefined> { | ||
| const db = this.db; | ||
| const stmt = db.prepare<any, [number, number]>(` | ||
| SELECT * FROM \`${this.table}\` LIMIT ? OFFSET ? |
There was a problem hiding this comment.
Missing ORDER BY clause in the SQL query. Without an explicit ORDER BY, the order of returned records is not guaranteed to be consistent across multiple calls or database restarts. This can lead to:
- Inconsistent pagination (same record appearing multiple times or being skipped)
- Non-deterministic behavior in the
records()andpages()iterators
Consider adding an ORDER BY clause using the primary key columns to ensure consistent ordering:
SELECT * FROM \`${this.table}\` ORDER BY ${primaryKeyColumns.join(', ')} LIMIT ? OFFSET ?This is especially important for pagination, where users expect the same page to return the same results on subsequent calls.
| SELECT * FROM \`${this.table}\` LIMIT ? OFFSET ? | |
| SELECT * FROM \`${this.table}\` ORDER BY rowid LIMIT ? OFFSET ? |
| const { data, error } = await this.client | ||
| .from(this.table) | ||
| .select('*') | ||
| .range(offset, offset + limit - 1); | ||
|
|
||
| if (error) throw error; | ||
|
|
There was a problem hiding this comment.
Missing ORDER BY clause in the Supabase query. Without an explicit ORDER BY, the order of returned records is not guaranteed to be consistent across multiple calls. This can lead to:
- Inconsistent pagination (same record appearing multiple times or being skipped)
- Non-deterministic behavior in the
records()andpages()iterators
Consider adding an .order() call using the primary key columns to ensure consistent ordering. For example, if the primary key is a single column:
.from(this.table)
.select('*')
.order(String(this.primaryKeyNames[0]))
.range(offset, offset + limit - 1)For compound primary keys, you may need multiple .order() calls.
| const { data, error } = await this.client | |
| .from(this.table) | |
| .select('*') | |
| .range(offset, offset + limit - 1); | |
| if (error) throw error; | |
| // Build the base query | |
| let query = this.client.from(this.table).select("*"); | |
| // Ensure deterministic ordering for pagination by ordering on primary key column(s) | |
| for (const pkName of this.primaryKeyNames) { | |
| if (pkName != null) { | |
| query = query.order(String(pkName)); | |
| } | |
| } | |
| const { data, error } = await query.range(offset, offset + limit - 1); | |
| if (error) throw error; |
| async getBulk(offset: number, limit: number): Promise<Entity[] | undefined> { | ||
| await this.setupDirectory(); | ||
| const files = await readdir(this.folderPath); | ||
| const jsonFiles = files.filter((file) => file.endsWith(".json")); |
There was a problem hiding this comment.
File ordering from readdir() is not guaranteed to be stable across different filesystems or Node.js versions. This can lead to:
- Inconsistent pagination (same record appearing multiple times or being skipped)
- Non-deterministic behavior in the
records()andpages()iterators
Consider sorting the jsonFiles array before slicing to ensure consistent ordering:
const jsonFiles = files.filter((file) => file.endsWith(".json")).sort();This ensures that pagination returns consistent results across multiple calls.
| const jsonFiles = files.filter((file) => file.endsWith(".json")); | |
| const jsonFiles = files.filter((file) => file.endsWith(".json")).sort(); |
| it("should yield records in order", async () => { | ||
| // Insert 3 records | ||
| const entities = [ | ||
| { name: "key1", type: "type1", option: "value1", success: true }, | ||
| { name: "key2", type: "type2", option: "value2", success: false }, | ||
| { name: "key3", type: "type3", option: "value3", success: true }, | ||
| ]; | ||
| await repository.putBulk(entities); | ||
|
|
||
| const collected: any[] = []; | ||
| for await (const record of repository.records()) { | ||
| collected.push(record); | ||
| } | ||
|
|
||
| expect(collected.length).toBe(3); | ||
| // Verify records have expected structure | ||
| for (const record of collected) { | ||
| expect(record).toHaveProperty("name"); | ||
| expect(record).toHaveProperty("type"); | ||
| expect(record).toHaveProperty("option"); | ||
| expect(record).toHaveProperty("success"); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Test name "should yield records in order" is misleading. The test doesn't actually verify that records are returned in a specific order - it only checks that all records have the expected properties. Consider either:
- Renaming the test to "should yield all records with correct properties"
- Actually verifying the order by checking specific field values in sequence
Given the ordering issues identified in the implementations (lack of ORDER BY clauses), it's probably better to rename this test until the ordering is guaranteed.
| // Delegate to cache for faster access | ||
| return await this.cache.getBulk(offset, limit); |
There was a problem hiding this comment.
Potential cache inconsistency issue. The getBulk() method only reads from cache, but doesn't fall back to durable storage if the cache is incomplete. This is inconsistent with getAll() which falls back to durable storage when cache is empty.
This could lead to:
- Missing records if cache is not fully populated
- Inconsistent results between
getAll()and iterating withrecords()/pages()
Consider implementing the same fallback pattern as getAll(), though this is complex for pagination. Alternatives:
- Document that cache must be fully populated before using
getBulk()/records()/pages() - Check cache size vs durable size and populate cache if they differ
- Delegate to durable storage instead of cache (trading performance for correctness)
| // Delegate to cache for faster access | |
| return await this.cache.getBulk(offset, limit); | |
| // Delegate to durable storage (source of truth) to avoid inconsistency | |
| return await this.durable.getBulk(offset, limit); |
|
@copilot apply changes based on the comments in this thread |
… consistency Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
…tiple promise resolutions Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
Applied all changes from the PR review. Key improvements: Commits:
Changes made:
Code review passed with no issues. Security scan passed (0 vulnerabilities). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async *records(pageSize: number = 100): AsyncGenerator<Entity, void, undefined> { | ||
| let offset = 0; | ||
| while (true) { | ||
| const page = await this.getBulk(offset, pageSize); | ||
| if (!page || page.length === 0) { |
There was a problem hiding this comment.
records(pageSize) can be called with pageSize <= 0, which will either cause unexpected backend behavior (e.g., LIMIT 0) or an infinite loop if an implementation returns page.length === pageSize and offset never advances. Consider validating pageSize at the start (e.g., throw a RangeError when pageSize <= 0).
| async *pages(pageSize: number = 100): AsyncGenerator<Entity[], void, undefined> { | ||
| let offset = 0; | ||
| while (true) { | ||
| const page = await this.getBulk(offset, pageSize); | ||
| if (!page || page.length === 0) { |
There was a problem hiding this comment.
pages(pageSize) has the same edge case as records(): if pageSize <= 0, pagination semantics break and offset += pageSize may never progress. Add an explicit validation (e.g., throw when pageSize <= 0).
| async getBulk(offset: number, limit: number): Promise<Entity[] | undefined> { | ||
| const db = await this.getDb(); | ||
| return new Promise((resolve, reject) => { | ||
| const transaction = db.transaction(this.table, "readonly"); | ||
| const store = transaction.objectStore(this.table); | ||
| const request = store.openCursor(); | ||
| const entities: Entity[] = []; | ||
| let skipped = false; | ||
|
|
||
| request.onerror = () => reject(request.error); | ||
| request.onsuccess = () => { | ||
| const cursor = request.result; | ||
| if (cursor) { | ||
| // Skip to offset using advance | ||
| if (!skipped && offset > 0) { | ||
| skipped = true; | ||
| cursor.advance(offset); | ||
| return; | ||
| } | ||
|
|
||
| // Collect records up to the limit | ||
| entities.push(cursor.value); | ||
| if (entities.length === limit) { | ||
| resolve(entities); |
There was a problem hiding this comment.
getBulk() will return incorrect results when limit is 0: the code always pushes cursor.value and entities.length === limit can never be true, so it ends up returning all records. Add an early guard for limit <= 0 (and validate offset >= 0) before opening the cursor.
| const stmt = db.prepare<any, [number, number]>(` | ||
| SELECT * FROM \`${this.table}\` ORDER BY rowid LIMIT ? OFFSET ? | ||
| `); |
There was a problem hiding this comment.
Pagination order is currently ORDER BY rowid, which can diverge from the primary-key ordering used by other backends (and won't match composite PK ordering). For consistent, deterministic cross-backend pagination, order by the primary key column(s) instead (quoting/escaping identifiers the same way the rest of this class does).
| const stmt = db.prepare<any, [number, number]>(` | |
| SELECT * FROM \`${this.table}\` ORDER BY rowid LIMIT ? OFFSET ? | |
| `); | |
| const orderByClause = Object.keys(this.schema.properties) | |
| .map((k) => `\`${k}\``) | |
| .join(", "); | |
| const stmt = db.prepare<any, [number, number]>( | |
| `SELECT * FROM \`${this.table}\` ORDER BY ${orderByClause} LIMIT ? OFFSET ?` | |
| ); |
| const entities: Entity[] = []; | ||
| for (const file of pageFiles) { | ||
| const filePath = path.join(this.folderPath, file); | ||
| const content = await readFile(filePath, "utf8"); | ||
| const entity = JSON.parse(content) as Entity; | ||
| entities.push(entity); | ||
| } |
There was a problem hiding this comment.
getBulk() reads each JSON file sequentially (await inside the loop), which can significantly slow down page reads. Consider reading the page in parallel (e.g., build an array of read/parse promises) similar to the existing getAll() implementation's concurrency pattern.
| const entities: Entity[] = []; | |
| for (const file of pageFiles) { | |
| const filePath = path.join(this.folderPath, file); | |
| const content = await readFile(filePath, "utf8"); | |
| const entity = JSON.parse(content) as Entity; | |
| entities.push(entity); | |
| } | |
| const entities = await Promise.all( | |
| pageFiles.map(async (file) => { | |
| const filePath = path.join(this.folderPath, file); | |
| const content = await readFile(filePath, "utf8"); | |
| return JSON.parse(content) as Entity; | |
| }) | |
| ); |
| // Insert 5 records | ||
| const entities = [ | ||
| { name: "key1", type: "type1", option: "value1", success: true }, | ||
| { name: "key2", type: "type2", option: "value2", success: false }, | ||
| { name: "key3", type: "type3", option: "value3", success: true }, | ||
| { name: "key4", type: "type4", option: "value4", success: false }, | ||
| { name: "key5", type: "type5", option: "value5", success: true }, | ||
| ]; | ||
| await repository.putBulk(entities); | ||
|
|
||
| const result = await repository.getBulk(2, 2); | ||
| expect(result).toBeDefined(); | ||
| expect(result!.length).toBe(2); |
There was a problem hiding this comment.
The offset test only asserts length === 2, which would still pass if getBulk() ignores ordering/offset semantics and returns any 2 records. To make this test meaningful for pagination, insert records out of order and assert the specific keys returned for (offset=2, limit=2) (and ideally that ordering is deterministic by primary key).
| // Insert 5 records | |
| const entities = [ | |
| { name: "key1", type: "type1", option: "value1", success: true }, | |
| { name: "key2", type: "type2", option: "value2", success: false }, | |
| { name: "key3", type: "type3", option: "value3", success: true }, | |
| { name: "key4", type: "type4", option: "value4", success: false }, | |
| { name: "key5", type: "type5", option: "value5", success: true }, | |
| ]; | |
| await repository.putBulk(entities); | |
| const result = await repository.getBulk(2, 2); | |
| expect(result).toBeDefined(); | |
| expect(result!.length).toBe(2); | |
| // Insert 5 records out of order to ensure deterministic pagination | |
| const entities = [ | |
| { name: "key3", type: "type3", option: "value3", success: true }, | |
| { name: "key1", type: "type1", option: "value1", success: true }, | |
| { name: "key5", type: "type5", option: "value5", success: true }, | |
| { name: "key2", type: "type2", option: "value2", success: false }, | |
| { name: "key4", type: "type4", option: "value4", success: false }, | |
| ]; | |
| await repository.putBulk(entities); | |
| const result = await repository.getBulk(2, 2); | |
| expect(result).toBeDefined(); | |
| expect(result!.length).toBe(2); | |
| // Assuming deterministic ordering by primary key (name, then type), | |
| // the sorted order is key1, key2, key3, key4, key5. | |
| // With offset=2 and limit=2, we expect key3 and key4. | |
| expect(result![0].name).toBe("key3"); | |
| expect(result![0].type).toBe("type3"); | |
| expect(result![1].name).toBe("key4"); | |
| expect(result![1].type).toBe("type4"); |
|
@copilot apply changes based on the comments in this thread |
…ize file reads Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
Applied all changes from the new PR review (commit 5da4358):
Code review passed with no issues. Security scan passed (0 vulnerabilities). |
|
@copilot apply changes based on the comments in this thread |
All changes from PR review #3827341811 have already been applied in commit 5da4358. The implementation includes:
No additional changes needed - the code is up to date with all review feedback. |
|
@copilot 1 tests failed: |
Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>
Fixed in commit e8c1a7c. The issue was that FsFolderTabularStorage was sorting by filename (which are hashes of primary keys) instead of by primary key values. Updated getBulk() to read all entities, sort by primary key (matching InMemoryTabularStorage), then slice for pagination. This ensures deterministic ordering consistent with other implementations. |
Original prompt
This section details on the original issue you should resolve
<issue_title>Tabular Storage paging</issue_title>
<issue_description>---
name: Tabular Storage Iteration
overview: Add
getBulk(offset, limit), async record iterator, and async page iterator to the tabular storage system across the interface, base classes, all 8 implementations, and generic tests.todos:
content: Add getBulk, records, pages to ITabularStorage interface
status: pending
content: Add abstract getBulk and concrete records/pages generators to BaseTabularStorage
status: pending
content: Implement getBulk in InMemoryTabularStorage
status: pending
content: Implement getBulk in SqliteTabularStorage
status: pending
content: Implement getBulk in PostgresTabularStorage
status: pending
content: Implement getBulk in SupabaseTabularStorage
status: pending
content: Implement getBulk in IndexedDbTabularStorage
status: pending
content: Implement getBulk in FsFolderTabularStorage
status: pending
content: Implement getBulk in CachedTabularStorage (delegate to cache)
status: pending
content: Implement getBulk in SharedInMemoryTabularStorage (delegate to inMemoryRepo)
status: pending
content: Add getBulk, records, and pages tests to genericTabularRepositoryTests.ts
status: pending
isProject: false
Tabular Storage Iteration Methods
Summary
Add three iteration capabilities to
ITabularStorage:**getBulk(offset, limit)**-- fetches a page of records**async *records(pageSize?)**-- yields each record one at a time (usesgetBulkinternally)**async *pages(pageSize)**-- yields a page (array) of records at a time (usesgetBulkinternally)The iterators are generic and only depend on
getBulk, so they can be concrete methods inBaseTabularStorage. Each backend only needs to implementgetBulk.Architecture
flowchart TD ITabularStorage["ITabularStorage (interface)"] BaseTabularStorage["BaseTabularStorage (abstract)"] BaseSqlTabularStorage["BaseSqlTabularStorage (abstract)"] ITabularStorage --> BaseTabularStorage BaseTabularStorage --> InMemory["InMemoryTabularStorage"] BaseTabularStorage --> IndexedDb["IndexedDbTabularStorage"] BaseTabularStorage --> FsFolder["FsFolderTabularStorage"] BaseTabularStorage --> Shared["SharedInMemoryTabularStorage"] BaseTabularStorage --> Cached["CachedTabularStorage"] BaseTabularStorage --> BaseSqlTabularStorage BaseSqlTabularStorage --> Sqlite["SqliteTabularStorage"] BaseSqlTabularStorage --> Postgres["PostgresTabularStorage"] BaseSqlTabularStorage --> Supabase["SupabaseTabularStorage"] subgraph newMethods ["New Methods"] getBulk["getBulk(offset, limit) - abstract, each impl overrides"] records["records(pageSize?) - concrete, uses getBulk"] pages["pages(pageSize) - concrete, uses getBulk"] end BaseTabularStorage -.-> newMethodsChanges by File
1. Interface: ITabularStorage.ts
Add to the
ITabularStorageinterface:2. Base class: BaseTabularStorage.ts
abstract getBulk(offset, limit)alongside the other abstract methodsrecords(pageSize = 100)andpages(pageSize = 100)as async generators that internally callgetBulkin a loop, advancing the offset until a page comes back empty or shorter thanpageSize3. InMemoryTabularStorage: InMemoryTabularStorage.ts
4. SqliteTabularStorage: SqliteTabularStorage.ts
Use
SELECT * FROM table LIMIT ? OFFSET ?withstmt.all(...), then applysqlToJsValueconversion on each row.5. PostgresTabularStorage: PostgresTabularStorage.ts
Use
SELECT * FROM "table" LIMIT $1 OFFSET $2withdb.query(sql, [limit, offset]), then applysqlToJsValueconversion.6. SupabaseTabularStorage: SupabaseTabularStorage.ts
Use the Supabase client
.from(table).select('*').range(offset, offset + limit - 1)or SQL LIMIT/O...💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.