@apibara/indexer 2.0.0-beta.0 → 2.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -19
- package/src/context.ts +8 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useSink.ts +13 -0
- package/src/index.ts +1 -0
- package/src/indexer.test.ts +70 -41
- package/src/indexer.ts +168 -168
- package/src/plugins/config.ts +4 -4
- package/src/plugins/kv.ts +2 -2
- package/src/plugins/persistence.test.ts +10 -6
- package/src/plugins/persistence.ts +3 -3
- package/src/sink.ts +21 -24
- package/src/sinks/csv.test.ts +15 -3
- package/src/sinks/csv.ts +68 -7
- package/src/sinks/drizzle/Int8Range.ts +52 -0
- package/src/sinks/drizzle/delete.ts +42 -0
- package/src/sinks/drizzle/drizzle.test.ts +239 -0
- package/src/sinks/drizzle/drizzle.ts +115 -0
- package/src/sinks/drizzle/index.ts +6 -0
- package/src/sinks/drizzle/insert.ts +39 -0
- package/src/sinks/drizzle/select.ts +44 -0
- package/src/sinks/drizzle/transaction.ts +49 -0
- package/src/sinks/drizzle/update.ts +47 -0
- package/src/sinks/drizzle/utils.ts +36 -0
- package/src/sinks/sqlite.test.ts +13 -1
- package/src/sinks/sqlite.ts +65 -5
- package/src/testing/indexer.ts +15 -8
- package/src/testing/setup.ts +5 -5
- package/src/testing/vcr.ts +42 -4
- package/src/vcr/record.ts +2 -2
- package/src/vcr/replay.ts +3 -3
- package/.turbo/turbo-build.log +0 -37
- package/CHANGELOG.md +0 -83
- package/LICENSE.txt +0 -202
- package/build.config.ts +0 -16
- package/dist/index.cjs +0 -34
- package/dist/index.d.cts +0 -21
- package/dist/index.d.mts +0 -21
- package/dist/index.d.ts +0 -21
- package/dist/index.mjs +0 -19
- package/dist/shared/indexer.371c0482.mjs +0 -15
- package/dist/shared/indexer.3852a4d3.d.ts +0 -91
- package/dist/shared/indexer.50aa7ab0.mjs +0 -268
- package/dist/shared/indexer.7c118fb5.d.cts +0 -28
- package/dist/shared/indexer.7c118fb5.d.mts +0 -28
- package/dist/shared/indexer.7c118fb5.d.ts +0 -28
- package/dist/shared/indexer.a27bcb35.d.cts +0 -91
- package/dist/shared/indexer.c8ef02ea.cjs +0 -289
- package/dist/shared/indexer.e05aedca.cjs +0 -19
- package/dist/shared/indexer.f7dd57e5.d.mts +0 -91
- package/dist/sinks/csv.cjs +0 -66
- package/dist/sinks/csv.d.cts +0 -34
- package/dist/sinks/csv.d.mts +0 -34
- package/dist/sinks/csv.d.ts +0 -34
- package/dist/sinks/csv.mjs +0 -59
- package/dist/sinks/sqlite.cjs +0 -71
- package/dist/sinks/sqlite.d.cts +0 -41
- package/dist/sinks/sqlite.d.mts +0 -41
- package/dist/sinks/sqlite.d.ts +0 -41
- package/dist/sinks/sqlite.mjs +0 -68
- package/dist/testing/index.cjs +0 -63
- package/dist/testing/index.d.cts +0 -29
- package/dist/testing/index.d.mts +0 -29
- package/dist/testing/index.d.ts +0 -29
- package/dist/testing/index.mjs +0 -59
- package/tsconfig.json +0 -11
package/src/sinks/csv.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import type { Cursor } from "@apibara/protocol";
|
|
3
3
|
import { type Options, type Stringifier, stringify } from "csv-stringify";
|
|
4
|
-
import { Sink, type
|
|
4
|
+
import { Sink, type SinkCursorParams, type SinkData } from "../sink";
|
|
5
5
|
|
|
6
6
|
export type CsvArgs = {
|
|
7
7
|
/**
|
|
@@ -23,6 +23,50 @@ export type CsvSinkOptions = {
|
|
|
23
23
|
cursorColumn?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
type TxnContext = {
|
|
27
|
+
buffer: SinkData[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TxnParams = {
|
|
31
|
+
writer: {
|
|
32
|
+
insert: (data: SinkData[]) => void;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const transactionHelper = (context: TxnContext) => {
|
|
37
|
+
return {
|
|
38
|
+
insert: (data: SinkData[]) => {
|
|
39
|
+
context.buffer.push(...data);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A sink that writes data to a CSV file.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* const sink = csv({
|
|
51
|
+
* filepath: "./data.csv",
|
|
52
|
+
* csvOptions: {
|
|
53
|
+
* header: true,
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* ...
|
|
58
|
+
* async transform({context, endCursor}){
|
|
59
|
+
* const { writer } = useSink(context);
|
|
60
|
+
* const insertHelper = writer(endCursor);
|
|
61
|
+
*
|
|
62
|
+
* insertHelper.insert([
|
|
63
|
+
* { id: 1, name: "John" },
|
|
64
|
+
* { id: 2, name: "Jane" },
|
|
65
|
+
* ]);
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
26
70
|
export class CsvSink extends Sink {
|
|
27
71
|
constructor(
|
|
28
72
|
private _stringifier: Stringifier,
|
|
@@ -31,14 +75,33 @@ export class CsvSink extends Sink {
|
|
|
31
75
|
super();
|
|
32
76
|
}
|
|
33
77
|
|
|
34
|
-
async write({
|
|
35
|
-
|
|
78
|
+
private async write({
|
|
79
|
+
data,
|
|
80
|
+
endCursor,
|
|
81
|
+
}: { data: SinkData[]; endCursor?: Cursor }) {
|
|
36
82
|
// adds a "_cursor" property if "cursorColumn" is not specified by user
|
|
37
83
|
data = this.processCursorColumn(data, endCursor);
|
|
38
84
|
// Insert the data into csv
|
|
39
85
|
await this.insertToCSV(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async transaction(
|
|
89
|
+
{ cursor, endCursor, finality }: SinkCursorParams,
|
|
90
|
+
cb: (params: TxnParams) => Promise<void>,
|
|
91
|
+
) {
|
|
92
|
+
const context: TxnContext = {
|
|
93
|
+
buffer: [],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const writer = transactionHelper(context);
|
|
97
|
+
|
|
98
|
+
await cb({ writer });
|
|
99
|
+
await this.write({ data: context.buffer, endCursor });
|
|
100
|
+
}
|
|
40
101
|
|
|
41
|
-
|
|
102
|
+
async invalidate(cursor?: Cursor) {
|
|
103
|
+
// TODO: Implement
|
|
104
|
+
throw new Error("Not implemented");
|
|
42
105
|
}
|
|
43
106
|
|
|
44
107
|
private async insertToCSV(data: SinkData[]) {
|
|
@@ -86,9 +149,7 @@ export class CsvSink extends Sink {
|
|
|
86
149
|
}
|
|
87
150
|
}
|
|
88
151
|
|
|
89
|
-
export const csv =
|
|
90
|
-
args: CsvArgs & CsvSinkOptions,
|
|
91
|
-
) => {
|
|
152
|
+
export const csv = (args: CsvArgs & CsvSinkOptions) => {
|
|
92
153
|
const { csvOptions, filepath, ...sinkOptions } = args;
|
|
93
154
|
const stringifier = stringify({ ...csvOptions });
|
|
94
155
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { customType } from "drizzle-orm/pg-core";
|
|
2
|
+
import type { Range } from "postgres-range";
|
|
3
|
+
import {
|
|
4
|
+
parse as rangeParse,
|
|
5
|
+
serialize as rangeSerialize,
|
|
6
|
+
} from "postgres-range";
|
|
7
|
+
|
|
8
|
+
type Comparable = string | number;
|
|
9
|
+
|
|
10
|
+
type RangeBound<T extends Comparable> =
|
|
11
|
+
| T
|
|
12
|
+
| {
|
|
13
|
+
value: T;
|
|
14
|
+
inclusive: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class Int8Range {
|
|
18
|
+
constructor(public readonly range: Range<number>) {}
|
|
19
|
+
|
|
20
|
+
get start(): RangeBound<number> | null {
|
|
21
|
+
return this.range.lower != null
|
|
22
|
+
? {
|
|
23
|
+
value: this.range.lower,
|
|
24
|
+
inclusive: this.range.isLowerBoundClosed(),
|
|
25
|
+
}
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get end(): RangeBound<number> | null {
|
|
30
|
+
return this.range.upper != null
|
|
31
|
+
? {
|
|
32
|
+
value: this.range.upper,
|
|
33
|
+
inclusive: this.range.isUpperBoundClosed(),
|
|
34
|
+
}
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const int8range = customType<{
|
|
40
|
+
data: Int8Range;
|
|
41
|
+
}>({
|
|
42
|
+
dataType: () => "int8range",
|
|
43
|
+
fromDriver: (value: unknown): Int8Range => {
|
|
44
|
+
if (typeof value !== "string") {
|
|
45
|
+
throw new Error("Expected string");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parsed = rangeParse(value, (val) => Number.parseInt(val, 10));
|
|
49
|
+
return new Int8Range(parsed);
|
|
50
|
+
},
|
|
51
|
+
toDriver: (value: Int8Range): string => rangeSerialize(value.range),
|
|
52
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import {
|
|
3
|
+
type ExtractTablesWithRelations,
|
|
4
|
+
type SQL,
|
|
5
|
+
type TablesRelationalConfig,
|
|
6
|
+
sql,
|
|
7
|
+
} from "drizzle-orm";
|
|
8
|
+
import type {
|
|
9
|
+
PgQueryResultHKT,
|
|
10
|
+
PgTable,
|
|
11
|
+
PgTransaction,
|
|
12
|
+
PgUpdateBase,
|
|
13
|
+
} from "drizzle-orm/pg-core";
|
|
14
|
+
|
|
15
|
+
export class DrizzleSinkDelete<
|
|
16
|
+
TTable extends PgTable,
|
|
17
|
+
TQueryResult extends PgQueryResultHKT,
|
|
18
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
19
|
+
TSchema extends
|
|
20
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
21
|
+
> {
|
|
22
|
+
constructor(
|
|
23
|
+
private db: PgTransaction<TQueryResult, TFullSchema, TSchema>,
|
|
24
|
+
private table: TTable,
|
|
25
|
+
private endCursor?: Cursor,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
//@ts-ignore
|
|
29
|
+
where(where: SQL): Omit<
|
|
30
|
+
// for type safety used PgUpdateBase instead of PgDeleteBase
|
|
31
|
+
PgUpdateBase<TTable, TQueryResult, undefined, false, "where">,
|
|
32
|
+
"where"
|
|
33
|
+
> {
|
|
34
|
+
return this.db
|
|
35
|
+
.update(this.table)
|
|
36
|
+
.set({
|
|
37
|
+
// @ts-ignore
|
|
38
|
+
_cursor: sql`int8range(lower(_cursor), ${Number(this.endCursor?.orderKey!)}, '[)')`,
|
|
39
|
+
})
|
|
40
|
+
.where(where);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import {
|
|
3
|
+
type MockBlock,
|
|
4
|
+
MockClient,
|
|
5
|
+
type MockFilter,
|
|
6
|
+
} from "@apibara/protocol/testing";
|
|
7
|
+
import { asc, eq, sql } from "drizzle-orm";
|
|
8
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
9
|
+
import { serial, text } from "drizzle-orm/pg-core";
|
|
10
|
+
import { Client } from "pg";
|
|
11
|
+
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
type Int8Range,
|
|
14
|
+
drizzle as drizzleSink,
|
|
15
|
+
getDrizzleCursor,
|
|
16
|
+
pgTable,
|
|
17
|
+
} from ".";
|
|
18
|
+
import { useSink } from "../../hooks";
|
|
19
|
+
import { run } from "../../indexer";
|
|
20
|
+
import { generateMockMessages } from "../../testing";
|
|
21
|
+
import { getMockIndexer } from "../../testing/indexer";
|
|
22
|
+
|
|
23
|
+
const testTable = pgTable("test_table", {
|
|
24
|
+
id: serial("id").primaryKey(),
|
|
25
|
+
data: text("data"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const client = new Client({
|
|
29
|
+
connectionString: "postgres://postgres:postgres@localhost:5432/postgres",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await client.connect();
|
|
33
|
+
|
|
34
|
+
const db = drizzle(client);
|
|
35
|
+
|
|
36
|
+
describe("Drizzle Test", () => {
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
// drop test_table if exists
|
|
39
|
+
await db.execute(sql`DROP TABLE IF EXISTS test_table`);
|
|
40
|
+
// create test_table with db
|
|
41
|
+
await db.execute(
|
|
42
|
+
sql`CREATE TABLE test_table (id SERIAL PRIMARY KEY, data TEXT, _cursor INT8RANGE)`,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
await db.delete(testTable).execute();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should insert data", async () => {
|
|
51
|
+
const client = new MockClient<MockFilter, MockBlock>((request, options) => {
|
|
52
|
+
return generateMockMessages(5);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const sink = drizzleSink({ database: db, tables: [testTable] });
|
|
56
|
+
|
|
57
|
+
const indexer = getMockIndexer({
|
|
58
|
+
sink,
|
|
59
|
+
override: {
|
|
60
|
+
transform: async ({ context, endCursor, block: { data } }) => {
|
|
61
|
+
const { db } = useSink({ context });
|
|
62
|
+
// Insert a new row into the test_table
|
|
63
|
+
// The id is set to the current cursor's orderKey
|
|
64
|
+
// The data is set to the block data
|
|
65
|
+
await db
|
|
66
|
+
.insert(testTable)
|
|
67
|
+
.values([{ id: Number(endCursor?.orderKey), data }]);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await run(client, indexer);
|
|
73
|
+
|
|
74
|
+
const result = await db.select().from(testTable).orderBy(asc(testTable.id));
|
|
75
|
+
|
|
76
|
+
expect(result).toHaveLength(5);
|
|
77
|
+
expect(result[0].data).toBe("5000000");
|
|
78
|
+
expect(result[2].data).toBe("5000002");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should update data", async () => {
|
|
82
|
+
const client = new MockClient<MockFilter, MockBlock>((request, options) => {
|
|
83
|
+
return generateMockMessages(5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const sink = drizzleSink({ database: db, tables: [testTable] });
|
|
87
|
+
|
|
88
|
+
const indexer = getMockIndexer({
|
|
89
|
+
sink,
|
|
90
|
+
override: {
|
|
91
|
+
transform: async ({ context, endCursor, block: { data } }) => {
|
|
92
|
+
const { db } = useSink({ context });
|
|
93
|
+
|
|
94
|
+
// insert data for each message in db
|
|
95
|
+
await db
|
|
96
|
+
.insert(testTable)
|
|
97
|
+
.values([{ id: Number(endCursor?.orderKey), data }]);
|
|
98
|
+
|
|
99
|
+
// update data for id 5000002 when orderKey is 5000004
|
|
100
|
+
// this is to test if the update query is working
|
|
101
|
+
if (endCursor?.orderKey === 5000004n) {
|
|
102
|
+
await db
|
|
103
|
+
.update(testTable)
|
|
104
|
+
.set({ data: "0000000" })
|
|
105
|
+
.where(eq(testTable.id, 5000002));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await run(client, indexer);
|
|
112
|
+
|
|
113
|
+
const result = await db.select().from(testTable).orderBy(asc(testTable.id));
|
|
114
|
+
|
|
115
|
+
expect(result).toHaveLength(5);
|
|
116
|
+
expect(result[2].data).toBe("0000000");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should delete data", async () => {
|
|
120
|
+
const client = new MockClient<MockFilter, MockBlock>((request, options) => {
|
|
121
|
+
return generateMockMessages(5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const sink = drizzleSink({ database: db, tables: [testTable] });
|
|
125
|
+
|
|
126
|
+
const indexer = getMockIndexer({
|
|
127
|
+
sink,
|
|
128
|
+
override: {
|
|
129
|
+
transform: async ({ context, endCursor, block: { data } }) => {
|
|
130
|
+
const { db } = useSink({ context });
|
|
131
|
+
|
|
132
|
+
// insert data for each message in db
|
|
133
|
+
await db
|
|
134
|
+
.insert(testTable)
|
|
135
|
+
.values([{ id: Number(endCursor?.orderKey), data }]);
|
|
136
|
+
|
|
137
|
+
// delete data for id 5000002 when orderKey is 5000004
|
|
138
|
+
// this is to test if the delete query is working
|
|
139
|
+
if (endCursor?.orderKey === 5000004n) {
|
|
140
|
+
await db.delete(testTable).where(eq(testTable.id, 5000002));
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await run(client, indexer);
|
|
147
|
+
|
|
148
|
+
const result = await db.select().from(testTable).orderBy(asc(testTable.id));
|
|
149
|
+
|
|
150
|
+
expect(result).toHaveLength(5);
|
|
151
|
+
|
|
152
|
+
// as when you run delete query on a data, it isnt literally deleted from the db,
|
|
153
|
+
// instead, we just update the upper bound of that row to the current cursor
|
|
154
|
+
// check if the cursor upper bound has been set correctly
|
|
155
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
156
|
+
expect(((result[2] as any)._cursor as Int8Range).range.upper).toBe(5000004);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should select data", async () => {
|
|
160
|
+
const client = new MockClient<MockFilter, MockBlock>((request, options) => {
|
|
161
|
+
return generateMockMessages(5);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const sink = drizzleSink({ database: db, tables: [testTable] });
|
|
165
|
+
|
|
166
|
+
let result: (typeof testTable.$inferSelect)[] = [];
|
|
167
|
+
|
|
168
|
+
const indexer = getMockIndexer({
|
|
169
|
+
sink,
|
|
170
|
+
override: {
|
|
171
|
+
transform: async ({ context, endCursor, block: { data } }) => {
|
|
172
|
+
const { db } = useSink({ context });
|
|
173
|
+
|
|
174
|
+
// insert data for each message in db
|
|
175
|
+
await db
|
|
176
|
+
.insert(testTable)
|
|
177
|
+
.values([{ id: Number(endCursor?.orderKey), data }]);
|
|
178
|
+
|
|
179
|
+
// delete data for id 5000002 when orderKey is 5000004
|
|
180
|
+
// this will update the upper bound of the row with id 5000002 from infinity to 5000004
|
|
181
|
+
// so when we select all rows, row with id 5000002 will not be included
|
|
182
|
+
// as when we run select query it should only return rows with upper bound infinity
|
|
183
|
+
if (endCursor?.orderKey === 5000003n) {
|
|
184
|
+
await db.delete(testTable).where(eq(testTable.id, 5000002));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// when on last message of mock stream, select all rows from db
|
|
188
|
+
if (endCursor?.orderKey === 5000004n) {
|
|
189
|
+
result = await db
|
|
190
|
+
.select()
|
|
191
|
+
.from(testTable)
|
|
192
|
+
.orderBy(asc(testTable.id));
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await run(client, indexer);
|
|
199
|
+
|
|
200
|
+
expect(result).toHaveLength(4);
|
|
201
|
+
expect(result.find((r) => r.id === 5000002)).toBeUndefined();
|
|
202
|
+
// check if all rows are still in db
|
|
203
|
+
const allRows = await db.select().from(testTable);
|
|
204
|
+
expect(allRows).toHaveLength(5);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should invalidate data correctly", async () => {
|
|
208
|
+
const sink = drizzleSink({ database: db, tables: [testTable] });
|
|
209
|
+
|
|
210
|
+
// Insert some test data
|
|
211
|
+
await db.insert(testTable).values(
|
|
212
|
+
// @ts-ignore
|
|
213
|
+
[
|
|
214
|
+
{ id: 1, data: "data1", _cursor: getDrizzleCursor([1n, 5n]) },
|
|
215
|
+
{ id: 2, data: "data2", _cursor: getDrizzleCursor([2n, 5n]) },
|
|
216
|
+
{ id: 3, data: "data3", _cursor: getDrizzleCursor(3n) },
|
|
217
|
+
{ id: 4, data: "data4", _cursor: getDrizzleCursor(4n) },
|
|
218
|
+
{ id: 5, data: "data5", _cursor: getDrizzleCursor(5n) },
|
|
219
|
+
],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Create a cursor at position 3
|
|
223
|
+
const cursor: Cursor = { orderKey: 3n };
|
|
224
|
+
|
|
225
|
+
// Invalidate data
|
|
226
|
+
await sink.invalidate(cursor);
|
|
227
|
+
|
|
228
|
+
// Check the results
|
|
229
|
+
const result = await db.select().from(testTable).orderBy(asc(testTable.id));
|
|
230
|
+
|
|
231
|
+
expect(result).toHaveLength(3);
|
|
232
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
233
|
+
expect(((result[0] as any)._cursor as Int8Range).range.upper).toBe(null);
|
|
234
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
235
|
+
expect(((result[1] as any)._cursor as Int8Range).range.upper).toBe(null);
|
|
236
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
237
|
+
expect(((result[2] as any)._cursor as Int8Range).range.upper).toBe(null);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import {
|
|
3
|
+
type ExtractTablesWithRelations,
|
|
4
|
+
type TablesRelationalConfig,
|
|
5
|
+
gt,
|
|
6
|
+
sql,
|
|
7
|
+
} from "drizzle-orm";
|
|
8
|
+
import type {
|
|
9
|
+
AnyPgTable,
|
|
10
|
+
PgDatabase,
|
|
11
|
+
PgQueryResultHKT,
|
|
12
|
+
PgTableWithColumns,
|
|
13
|
+
TableConfig,
|
|
14
|
+
} from "drizzle-orm/pg-core";
|
|
15
|
+
import { Sink, type SinkCursorParams } from "../../sink";
|
|
16
|
+
import { DrizzleSinkTransaction } from "./transaction";
|
|
17
|
+
|
|
18
|
+
export type DrizzleSinkTables<
|
|
19
|
+
TTableConfig extends Record<string, TableConfig>,
|
|
20
|
+
> = {
|
|
21
|
+
[K in keyof TTableConfig]: PgTableWithColumns<TTableConfig[K]>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DrizzleSinkOptions<
|
|
25
|
+
TQueryResult extends PgQueryResultHKT,
|
|
26
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
27
|
+
TSchema extends
|
|
28
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
29
|
+
> = {
|
|
30
|
+
/**
|
|
31
|
+
* Database instance of drizzle-orm
|
|
32
|
+
*/
|
|
33
|
+
database: PgDatabase<TQueryResult, TFullSchema, TSchema>;
|
|
34
|
+
tables: AnyPgTable[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A sink that writes data to a PostgreSQL database using Drizzle ORM.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* const sink = drizzle({
|
|
44
|
+
* database: db,
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* ...
|
|
48
|
+
* async transform({context, endCursor}){
|
|
49
|
+
* const { transaction } = useSink(context);
|
|
50
|
+
* const db = transaction(endCursor);
|
|
51
|
+
*
|
|
52
|
+
* db.insert(users).values([
|
|
53
|
+
* { id: 1, name: "John" },
|
|
54
|
+
* { id: 2, name: "Jane" },
|
|
55
|
+
* ]);
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export class DrizzleSink<
|
|
61
|
+
TQueryResult extends PgQueryResultHKT,
|
|
62
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
63
|
+
TSchema extends
|
|
64
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
65
|
+
> extends Sink {
|
|
66
|
+
private _db: PgDatabase<TQueryResult, TFullSchema, TSchema>;
|
|
67
|
+
private _tables: AnyPgTable[];
|
|
68
|
+
constructor(options: DrizzleSinkOptions<TQueryResult, TFullSchema, TSchema>) {
|
|
69
|
+
super();
|
|
70
|
+
const { database, tables } = options;
|
|
71
|
+
this._db = database;
|
|
72
|
+
this._tables = tables;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async transaction(
|
|
76
|
+
{ cursor, endCursor, finality }: SinkCursorParams,
|
|
77
|
+
cb: (params: {
|
|
78
|
+
db: DrizzleSinkTransaction<TQueryResult, TFullSchema, TSchema>;
|
|
79
|
+
}) => Promise<void>,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await this._db.transaction(async (db) => {
|
|
82
|
+
await cb({ db: new DrizzleSinkTransaction(db, endCursor) });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async invalidate(cursor?: Cursor) {
|
|
87
|
+
await this._db.transaction(async (db) => {
|
|
88
|
+
for (const table of this._tables) {
|
|
89
|
+
// delete all rows whose lowerbound of "_cursor" (int8range) column is greater than the invalidate cursor
|
|
90
|
+
await db
|
|
91
|
+
.delete(table)
|
|
92
|
+
.where(gt(sql`lower(_cursor)`, sql`${Number(cursor?.orderKey)}`))
|
|
93
|
+
.returning();
|
|
94
|
+
// and for rows whose upperbound of "_cursor" (int8range) column is greater than the invalidate cursor, set the upperbound to infinity
|
|
95
|
+
await db
|
|
96
|
+
.update(table)
|
|
97
|
+
.set({
|
|
98
|
+
_cursor: sql`int8range(lower(_cursor), NULL, '[)')`,
|
|
99
|
+
})
|
|
100
|
+
.where(gt(sql`upper(_cursor)`, sql`${Number(cursor?.orderKey)}`));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const drizzle = <
|
|
107
|
+
TQueryResult extends PgQueryResultHKT,
|
|
108
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
109
|
+
TSchema extends
|
|
110
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
111
|
+
>(
|
|
112
|
+
args: DrizzleSinkOptions<TQueryResult, TFullSchema, TSchema>,
|
|
113
|
+
) => {
|
|
114
|
+
return new DrizzleSink(args);
|
|
115
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import type {
|
|
3
|
+
ExtractTablesWithRelations,
|
|
4
|
+
TablesRelationalConfig,
|
|
5
|
+
} from "drizzle-orm";
|
|
6
|
+
import type {
|
|
7
|
+
PgInsertValue,
|
|
8
|
+
PgQueryResultHKT,
|
|
9
|
+
PgTable,
|
|
10
|
+
PgTransaction,
|
|
11
|
+
} from "drizzle-orm/pg-core";
|
|
12
|
+
import { getDrizzleCursor } from "./utils";
|
|
13
|
+
|
|
14
|
+
export class DrizzleSinkInsert<
|
|
15
|
+
TTable extends PgTable,
|
|
16
|
+
TQueryResult extends PgQueryResultHKT,
|
|
17
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
18
|
+
TSchema extends
|
|
19
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
20
|
+
> {
|
|
21
|
+
constructor(
|
|
22
|
+
private db: PgTransaction<TQueryResult, TFullSchema, TSchema>,
|
|
23
|
+
private table: TTable,
|
|
24
|
+
private endCursor?: Cursor,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
values(values: PgInsertValue<TTable> | PgInsertValue<TTable>[]) {
|
|
28
|
+
const originalInsert = this.db.insert(this.table);
|
|
29
|
+
const cursoredValues = (Array.isArray(values) ? values : [values]).map(
|
|
30
|
+
(v) => {
|
|
31
|
+
return {
|
|
32
|
+
...v,
|
|
33
|
+
_cursor: getDrizzleCursor(this.endCursor?.orderKey),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
return originalInsert.values(cursoredValues as PgInsertValue<TTable>[]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import {
|
|
3
|
+
type ExtractTablesWithRelations,
|
|
4
|
+
type SQL,
|
|
5
|
+
type Subquery,
|
|
6
|
+
type TablesRelationalConfig,
|
|
7
|
+
sql,
|
|
8
|
+
} from "drizzle-orm";
|
|
9
|
+
import type {
|
|
10
|
+
PgQueryResultHKT,
|
|
11
|
+
PgTable,
|
|
12
|
+
PgTransaction,
|
|
13
|
+
SelectedFields,
|
|
14
|
+
} from "drizzle-orm/pg-core";
|
|
15
|
+
import type { PgViewBase } from "drizzle-orm/pg-core/view-base";
|
|
16
|
+
|
|
17
|
+
export class DrizzleSinkSelect<
|
|
18
|
+
TSelection extends SelectedFields,
|
|
19
|
+
TQueryResult extends PgQueryResultHKT,
|
|
20
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
21
|
+
TSchema extends
|
|
22
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
23
|
+
> {
|
|
24
|
+
constructor(
|
|
25
|
+
private db: PgTransaction<TQueryResult, TFullSchema, TSchema>,
|
|
26
|
+
private fields?: TSelection,
|
|
27
|
+
private endCursor?: Cursor,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
from<TFrom extends PgTable | Subquery | PgViewBase | SQL>(source: TFrom) {
|
|
31
|
+
if (this.fields) {
|
|
32
|
+
const originalFrom = this.db.select(this.fields).from(source);
|
|
33
|
+
return {
|
|
34
|
+
...originalFrom,
|
|
35
|
+
where: (where?: SQL) => {
|
|
36
|
+
const combinedWhere = sql`${where ? sql`${where} AND ` : sql``}upper_inf(_cursor)`;
|
|
37
|
+
return originalFrom.where(combinedWhere);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return this.db.select().from(source).where(sql`upper_inf(_cursor)`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import type {
|
|
3
|
+
ExtractTablesWithRelations,
|
|
4
|
+
TablesRelationalConfig,
|
|
5
|
+
} from "drizzle-orm";
|
|
6
|
+
import type {
|
|
7
|
+
PgQueryResultHKT,
|
|
8
|
+
PgSelectBuilder,
|
|
9
|
+
PgTable,
|
|
10
|
+
PgTransaction,
|
|
11
|
+
SelectedFields,
|
|
12
|
+
} from "drizzle-orm/pg-core";
|
|
13
|
+
import { DrizzleSinkDelete } from "./delete";
|
|
14
|
+
import { DrizzleSinkInsert } from "./insert";
|
|
15
|
+
import { DrizzleSinkSelect } from "./select";
|
|
16
|
+
import { DrizzleSinkUpdate } from "./update";
|
|
17
|
+
|
|
18
|
+
export class DrizzleSinkTransaction<
|
|
19
|
+
TQueryResult extends PgQueryResultHKT,
|
|
20
|
+
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
21
|
+
TSchema extends
|
|
22
|
+
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
|
|
23
|
+
> {
|
|
24
|
+
constructor(
|
|
25
|
+
private db: PgTransaction<TQueryResult, TFullSchema, TSchema>,
|
|
26
|
+
private endCursor?: Cursor,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
insert<TTable extends PgTable>(table: TTable) {
|
|
30
|
+
return new DrizzleSinkInsert(this.db, table, this.endCursor);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
update<TTable extends PgTable>(table: TTable) {
|
|
34
|
+
return new DrizzleSinkUpdate(this.db, table, this.endCursor);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
delete<TTable extends PgTable>(table: TTable) {
|
|
38
|
+
return new DrizzleSinkDelete(this.db, table, this.endCursor);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
select(): PgSelectBuilder<undefined>;
|
|
43
|
+
select<TSelection extends SelectedFields>(
|
|
44
|
+
fields: TSelection,
|
|
45
|
+
): PgSelectBuilder<TSelection>;
|
|
46
|
+
select(fields?: SelectedFields) {
|
|
47
|
+
return new DrizzleSinkSelect(this.db, fields, this.endCursor);
|
|
48
|
+
}
|
|
49
|
+
}
|