envio 2.26.0-rc.0 → 2.26.0-rc.1
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/evm.schema.json +7 -0
- package/fuel.schema.json +7 -0
- package/index.d.ts +6 -22
- package/index.js +2 -1
- package/package.json +5 -5
- package/src/Envio.gen.ts +3 -1
- package/src/Envio.res +13 -0
- package/src/Envio.res.js +9 -0
- package/src/FetchState.res +1 -4
- package/src/Internal.res +3 -0
- package/src/Logging.res +8 -0
- package/src/Logging.res.js +29 -0
- package/src/Persistence.res +110 -47
- package/src/Persistence.res.js +61 -17
- package/src/PgStorage.res +519 -86
- package/src/PgStorage.res.js +386 -57
- package/src/Prometheus.res +12 -0
- package/src/Prometheus.res.js +12 -0
- package/src/Utils.res +39 -9
- package/src/Utils.res.js +17 -6
- package/src/bindings/BigInt.gen.ts +10 -0
- package/src/bindings/BigInt.res +1 -0
- package/src/bindings/NodeJs.res +27 -26
- package/src/bindings/NodeJs.res.js +5 -13
- package/src/db/EntityHistory.res +5 -28
- package/src/db/EntityHistory.res.js +4 -23
- package/src/db/Table.res +3 -61
- package/src/db/Table.res.js +3 -42
package/src/PgStorage.res
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
let
|
|
1
|
+
let makeCreateIndexQuery = (~tableName, ~indexFields, ~pgSchema) => {
|
|
2
2
|
let indexName = tableName ++ "_" ++ indexFields->Js.Array2.joinWith("_")
|
|
3
3
|
let index = indexFields->Belt.Array.map(idx => `"${idx}"`)->Js.Array2.joinWith(", ")
|
|
4
4
|
`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}"(${index});`
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
let
|
|
7
|
+
let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
|
|
8
8
|
open Belt
|
|
9
9
|
let tableName = table.tableName
|
|
10
10
|
let createIndex = indexField =>
|
|
11
|
-
|
|
11
|
+
makeCreateIndexQuery(~tableName, ~indexFields=[indexField], ~pgSchema)
|
|
12
12
|
let createCompositeIndex = indexFields => {
|
|
13
|
-
|
|
13
|
+
makeCreateIndexQuery(~tableName, ~indexFields, ~pgSchema)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
let singleIndices = table->Table.getSingleIndices
|
|
@@ -20,7 +20,7 @@ let makeCreateTableIndicesSql = (table: Table.table, ~pgSchema) => {
|
|
|
20
20
|
compositeIndices->Array.map(createCompositeIndex)->Js.Array2.joinWith("\n")
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
let
|
|
23
|
+
let makeCreateTableQuery = (table: Table.table, ~pgSchema) => {
|
|
24
24
|
open Belt
|
|
25
25
|
let fieldsMapped =
|
|
26
26
|
table
|
|
@@ -58,7 +58,7 @@ let makeInitializeTransaction = (
|
|
|
58
58
|
~generalTables=[],
|
|
59
59
|
~entities=[],
|
|
60
60
|
~enums=[],
|
|
61
|
-
~
|
|
61
|
+
~isEmptyPgSchema=false,
|
|
62
62
|
) => {
|
|
63
63
|
let allTables = generalTables->Array.copy
|
|
64
64
|
let allEntityTables = []
|
|
@@ -71,12 +71,16 @@ let makeInitializeTransaction = (
|
|
|
71
71
|
|
|
72
72
|
let query = ref(
|
|
73
73
|
(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
isEmptyPgSchema && pgSchema === "public"
|
|
75
|
+
// Hosted Service already have a DB with the created public schema
|
|
76
|
+
// It also doesn't allow to simply drop it,
|
|
77
|
+
// so we reuse the existing schema when it's empty
|
|
78
|
+
// (but only for public, since it's usually always exists)
|
|
79
|
+
? ""
|
|
80
|
+
: `DROP SCHEMA IF EXISTS "${pgSchema}" CASCADE;
|
|
81
|
+
CREATE SCHEMA "${pgSchema}";\n`
|
|
78
82
|
) ++
|
|
79
|
-
`GRANT ALL ON SCHEMA "${pgSchema}" TO ${pgUser};
|
|
83
|
+
`GRANT ALL ON SCHEMA "${pgSchema}" TO "${pgUser}";
|
|
80
84
|
GRANT ALL ON SCHEMA "${pgSchema}" TO public;`,
|
|
81
85
|
)
|
|
82
86
|
|
|
@@ -87,31 +91,17 @@ GRANT ALL ON SCHEMA "${pgSchema}" TO public;`,
|
|
|
87
91
|
->Js.Array2.map(v => `'${v->(Utils.magic: Internal.enum => string)}'`)
|
|
88
92
|
->Js.Array2.joinWith(", ")});`
|
|
89
93
|
|
|
90
|
-
query :=
|
|
91
|
-
query.contents ++
|
|
92
|
-
"\n" ++ if cleanRun {
|
|
93
|
-
// Direct creation when cleanRunting (faster)
|
|
94
|
-
enumCreateQuery
|
|
95
|
-
} else {
|
|
96
|
-
// Wrap with conditional check only when not cleanRunting
|
|
97
|
-
`IF NOT EXISTS (
|
|
98
|
-
SELECT 1 FROM pg_type
|
|
99
|
-
WHERE typname = '${enumConfig.name->Js.String2.toLowerCase}'
|
|
100
|
-
AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '${pgSchema}')
|
|
101
|
-
) THEN
|
|
102
|
-
${enumCreateQuery}
|
|
103
|
-
END IF;`
|
|
104
|
-
}
|
|
94
|
+
query := query.contents ++ "\n" ++ enumCreateQuery
|
|
105
95
|
})
|
|
106
96
|
|
|
107
97
|
// Batch all table creation first (optimal for PostgreSQL)
|
|
108
98
|
allTables->Js.Array2.forEach((table: Table.table) => {
|
|
109
|
-
query := query.contents ++ "\n" ++
|
|
99
|
+
query := query.contents ++ "\n" ++ makeCreateTableQuery(table, ~pgSchema)
|
|
110
100
|
})
|
|
111
101
|
|
|
112
102
|
// Then batch all indices (better performance when tables exist)
|
|
113
103
|
allTables->Js.Array2.forEach((table: Table.table) => {
|
|
114
|
-
let indices =
|
|
104
|
+
let indices = makeCreateTableIndicesQuery(table, ~pgSchema)
|
|
115
105
|
if indices !== "" {
|
|
116
106
|
query := query.contents ++ "\n" ++ indices
|
|
117
107
|
}
|
|
@@ -131,7 +121,7 @@ END IF;`
|
|
|
131
121
|
query :=
|
|
132
122
|
query.contents ++
|
|
133
123
|
"\n" ++
|
|
134
|
-
|
|
124
|
+
makeCreateIndexQuery(
|
|
135
125
|
~tableName=derivedFromField.derivedFromEntity,
|
|
136
126
|
~indexFields=[indexField],
|
|
137
127
|
~pgSchema,
|
|
@@ -139,28 +129,40 @@ END IF;`
|
|
|
139
129
|
})
|
|
140
130
|
})
|
|
141
131
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
132
|
+
// Add cache row count function
|
|
133
|
+
functionsQuery :=
|
|
134
|
+
functionsQuery.contents ++
|
|
135
|
+
"\n" ++
|
|
136
|
+
`CREATE OR REPLACE FUNCTION get_cache_row_count(table_name text)
|
|
137
|
+
RETURNS integer AS $$
|
|
138
|
+
DECLARE
|
|
139
|
+
result integer;
|
|
140
|
+
BEGIN
|
|
141
|
+
EXECUTE format('SELECT COUNT(*) FROM "${pgSchema}".%I', table_name) INTO result;
|
|
142
|
+
RETURN result;
|
|
143
|
+
END;
|
|
144
|
+
$$ LANGUAGE plpgsql;`
|
|
145
|
+
|
|
146
|
+
[query.contents]->Js.Array2.concat(
|
|
147
|
+
functionsQuery.contents !== "" ? [functionsQuery.contents] : [],
|
|
148
|
+
)
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
let
|
|
151
|
+
let makeLoadByIdQuery = (~pgSchema, ~tableName) => {
|
|
154
152
|
`SELECT * FROM "${pgSchema}"."${tableName}" WHERE id = $1 LIMIT 1;`
|
|
155
153
|
}
|
|
156
154
|
|
|
157
|
-
let
|
|
155
|
+
let makeLoadByFieldQuery = (~pgSchema, ~tableName, ~fieldName, ~operator) => {
|
|
156
|
+
`SELECT * FROM "${pgSchema}"."${tableName}" WHERE "${fieldName}" ${operator} $1;`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let makeLoadByIdsQuery = (~pgSchema, ~tableName) => {
|
|
158
160
|
`SELECT * FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::text[]);`
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
let
|
|
163
|
+
let makeInsertUnnestSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema, ~isRawEvents) => {
|
|
162
164
|
let {quotedFieldNames, quotedNonPrimaryFieldNames, arrayFieldTypes} =
|
|
163
|
-
table->Table.toSqlParams(~schema=itemSchema)
|
|
165
|
+
table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
|
|
164
166
|
|
|
165
167
|
let primaryKeyFieldNames = Table.getPrimaryKeyFieldNames(table)
|
|
166
168
|
|
|
@@ -188,8 +190,9 @@ SELECT * FROM unnest(${arrayFieldTypes
|
|
|
188
190
|
} ++ ";"
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
let
|
|
192
|
-
let {quotedFieldNames, quotedNonPrimaryFieldNames} =
|
|
193
|
+
let makeInsertValuesSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema, ~itemsCount) => {
|
|
194
|
+
let {quotedFieldNames, quotedNonPrimaryFieldNames} =
|
|
195
|
+
table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
|
|
193
196
|
|
|
194
197
|
let primaryKeyFieldNames = Table.getPrimaryKeyFieldNames(table)
|
|
195
198
|
let fieldsCount = quotedFieldNames->Array.length
|
|
@@ -236,12 +239,13 @@ VALUES${placeholders.contents}` ++
|
|
|
236
239
|
// they are always guaranteed to be an object.
|
|
237
240
|
// FIXME what about Fuel params?
|
|
238
241
|
let rawEventsTableName = "raw_events"
|
|
242
|
+
let eventSyncStateTableName = "event_sync_state"
|
|
239
243
|
|
|
240
244
|
// Constants for chunking
|
|
241
245
|
let maxItemsPerQuery = 500
|
|
242
246
|
|
|
243
247
|
let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'item>) => {
|
|
244
|
-
let {dbSchema, hasArrayField} = table->Table.toSqlParams(~schema=itemSchema)
|
|
248
|
+
let {dbSchema, hasArrayField} = table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
|
|
245
249
|
let isRawEvents = table.tableName === rawEventsTableName
|
|
246
250
|
|
|
247
251
|
// Should experiment how much it'll affect performance
|
|
@@ -253,7 +257,7 @@ let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'
|
|
|
253
257
|
|
|
254
258
|
if isRawEvents || !hasArrayField {
|
|
255
259
|
{
|
|
256
|
-
"
|
|
260
|
+
"query": makeInsertUnnestSetQuery(~pgSchema, ~table, ~itemSchema, ~isRawEvents),
|
|
257
261
|
"convertOrThrow": S.compile(
|
|
258
262
|
S.unnest(dbSchema),
|
|
259
263
|
~input=Value,
|
|
@@ -265,7 +269,12 @@ let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'
|
|
|
265
269
|
}
|
|
266
270
|
} else {
|
|
267
271
|
{
|
|
268
|
-
"
|
|
272
|
+
"query": makeInsertValuesSetQuery(
|
|
273
|
+
~pgSchema,
|
|
274
|
+
~table,
|
|
275
|
+
~itemSchema,
|
|
276
|
+
~itemsCount=maxItemsPerQuery,
|
|
277
|
+
),
|
|
269
278
|
"convertOrThrow": S.compile(
|
|
270
279
|
S.unnest(itemSchema)->S.preprocess(_ => {
|
|
271
280
|
serializer: Utils.Array.flatten->Utils.magic,
|
|
@@ -291,6 +300,35 @@ let chunkArray = (arr: array<'a>, ~chunkSize) => {
|
|
|
291
300
|
chunks
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
let removeInvalidUtf8InPlace = entities =>
|
|
304
|
+
entities->Js.Array2.forEach(item => {
|
|
305
|
+
let dict = item->(Utils.magic: 'a => dict<unknown>)
|
|
306
|
+
dict->Utils.Dict.forEachWithKey((key, value) => {
|
|
307
|
+
if value->Js.typeof === "string" {
|
|
308
|
+
let value = value->(Utils.magic: unknown => string)
|
|
309
|
+
// We mutate here, since we don't care
|
|
310
|
+
// about the original value with \x00 anyways.
|
|
311
|
+
//
|
|
312
|
+
// This is unsafe, but we rely that it'll use
|
|
313
|
+
// the mutated reference on retry.
|
|
314
|
+
// TODO: Test it properly after we start using
|
|
315
|
+
// in-memory PGLite for indexer test framework.
|
|
316
|
+
dict->Js.Dict.set(
|
|
317
|
+
key,
|
|
318
|
+
value
|
|
319
|
+
->Utils.String.replaceAll("\x00", "")
|
|
320
|
+
->(Utils.magic: string => unknown),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
let pgEncodingErrorSchema = S.object(s =>
|
|
327
|
+
s.tag("message", `invalid byte sequence for encoding "UTF8": 0x00`)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
exception PgEncodingError({table: Table.table})
|
|
331
|
+
|
|
294
332
|
// WeakMap for caching table batch set queries
|
|
295
333
|
let setQueryCache = Utils.WeakMap.make()
|
|
296
334
|
let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema) => {
|
|
@@ -298,7 +336,7 @@ let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema
|
|
|
298
336
|
()
|
|
299
337
|
} else {
|
|
300
338
|
// Get or create cached query for this table
|
|
301
|
-
let
|
|
339
|
+
let data = switch setQueryCache->Utils.WeakMap.get(table) {
|
|
302
340
|
| Some(cached) => cached
|
|
303
341
|
| None => {
|
|
304
342
|
let newQuery = makeTableBatchSetQuery(
|
|
@@ -311,50 +349,31 @@ let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema
|
|
|
311
349
|
}
|
|
312
350
|
}
|
|
313
351
|
|
|
314
|
-
let sqlQuery = query["sql"]
|
|
315
|
-
|
|
316
352
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
Utils.magic: unknown => array<unknown>
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
if query["isInsertValues"] {
|
|
323
|
-
let fieldsCount = switch itemSchema->S.classify {
|
|
324
|
-
| S.Object({items}) => items->Array.length
|
|
325
|
-
| _ => Js.Exn.raiseError("Expected an object schema for table")
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Chunk the items for VALUES-based queries
|
|
329
|
-
// We need to multiply by fields count,
|
|
330
|
-
// because we flattened our entity values with S.unnest
|
|
331
|
-
// to optimize the query execution.
|
|
332
|
-
let maxChunkSize = maxItemsPerQuery * fieldsCount
|
|
333
|
-
let chunks = chunkArray(payload, ~chunkSize=maxChunkSize)
|
|
353
|
+
if data["isInsertValues"] {
|
|
354
|
+
let chunks = chunkArray(items, ~chunkSize=maxItemsPerQuery)
|
|
334
355
|
let responses = []
|
|
335
356
|
chunks->Js.Array2.forEach(chunk => {
|
|
336
357
|
let chunkSize = chunk->Array.length
|
|
337
|
-
let isFullChunk = chunkSize ===
|
|
358
|
+
let isFullChunk = chunkSize === maxItemsPerQuery
|
|
338
359
|
|
|
339
360
|
let response = sql->Postgres.preparedUnsafe(
|
|
340
361
|
// Either use the sql query for full chunks from cache
|
|
341
362
|
// or create a new one for partial chunks on the fly.
|
|
342
363
|
isFullChunk
|
|
343
|
-
?
|
|
344
|
-
:
|
|
345
|
-
|
|
346
|
-
~table,
|
|
347
|
-
~itemSchema,
|
|
348
|
-
~itemsCount=chunkSize / fieldsCount,
|
|
349
|
-
),
|
|
350
|
-
chunk->Utils.magic,
|
|
364
|
+
? data["query"]
|
|
365
|
+
: makeInsertValuesSetQuery(~pgSchema, ~table, ~itemSchema, ~itemsCount=chunkSize),
|
|
366
|
+
data["convertOrThrow"](chunk->(Utils.magic: array<'item> => array<unknown>)),
|
|
351
367
|
)
|
|
352
368
|
responses->Js.Array2.push(response)->ignore
|
|
353
369
|
})
|
|
354
370
|
let _ = await Promise.all(responses)
|
|
355
371
|
} else {
|
|
356
372
|
// Use UNNEST approach for single query
|
|
357
|
-
await sql->Postgres.preparedUnsafe(
|
|
373
|
+
await sql->Postgres.preparedUnsafe(
|
|
374
|
+
data["query"],
|
|
375
|
+
data["convertOrThrow"](items->(Utils.magic: array<'item> => array<unknown>)),
|
|
376
|
+
)
|
|
358
377
|
}
|
|
359
378
|
} catch {
|
|
360
379
|
| S.Raised(_) as exn =>
|
|
@@ -368,35 +387,201 @@ let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema
|
|
|
368
387
|
raise(
|
|
369
388
|
Persistence.StorageError({
|
|
370
389
|
message: `Failed to insert items into table "${table.tableName}"`,
|
|
371
|
-
reason: exn,
|
|
390
|
+
reason: exn->Internal.prettifyExn,
|
|
372
391
|
}),
|
|
373
392
|
)
|
|
374
393
|
}
|
|
375
394
|
}
|
|
376
395
|
}
|
|
377
396
|
|
|
378
|
-
let
|
|
397
|
+
let setEntityHistoryOrThrow = (
|
|
398
|
+
sql,
|
|
399
|
+
~entityHistory: EntityHistory.t<'entity>,
|
|
400
|
+
~rows: array<EntityHistory.historyRow<'entity>>,
|
|
401
|
+
~shouldCopyCurrentEntity=?,
|
|
402
|
+
~shouldRemoveInvalidUtf8=false,
|
|
403
|
+
) => {
|
|
404
|
+
rows
|
|
405
|
+
->Belt.Array.map(historyRow => {
|
|
406
|
+
let row = historyRow->S.reverseConvertToJsonOrThrow(entityHistory.schema)
|
|
407
|
+
if shouldRemoveInvalidUtf8 {
|
|
408
|
+
[row]->removeInvalidUtf8InPlace
|
|
409
|
+
}
|
|
410
|
+
entityHistory.insertFn(
|
|
411
|
+
sql,
|
|
412
|
+
row,
|
|
413
|
+
~shouldCopyCurrentEntity=switch shouldCopyCurrentEntity {
|
|
414
|
+
| Some(v) => v
|
|
415
|
+
| None => {
|
|
416
|
+
let containsRollbackDiffChange =
|
|
417
|
+
historyRow.containsRollbackDiffChange->Belt.Option.getWithDefault(false)
|
|
418
|
+
!containsRollbackDiffChange
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
})
|
|
423
|
+
->Promise.all
|
|
424
|
+
->(Utils.magic: promise<array<unit>> => promise<unit>)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
type schemaTableName = {
|
|
428
|
+
@as("table_name")
|
|
429
|
+
tableName: string,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let makeSchemaTableNamesQuery = (~pgSchema) => {
|
|
433
|
+
`SELECT table_name FROM information_schema.tables WHERE table_schema = '${pgSchema}';`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let cacheTablePrefix = "envio_effect_"
|
|
437
|
+
let cacheTablePrefixLength = cacheTablePrefix->String.length
|
|
438
|
+
|
|
439
|
+
type schemaCacheTableInfo = {
|
|
440
|
+
@as("table_name")
|
|
441
|
+
tableName: string,
|
|
442
|
+
@as("count")
|
|
443
|
+
count: int,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let makeSchemaCacheTableInfoQuery = (~pgSchema) => {
|
|
447
|
+
`SELECT
|
|
448
|
+
t.table_name,
|
|
449
|
+
get_cache_row_count(t.table_name) as count
|
|
450
|
+
FROM information_schema.tables t
|
|
451
|
+
WHERE t.table_schema = '${pgSchema}'
|
|
452
|
+
AND t.table_name LIKE '${cacheTablePrefix}%';`
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
type psqlExecState =
|
|
456
|
+
Unknown | Pending(promise<result<string, string>>) | Resolved(result<string, string>)
|
|
457
|
+
|
|
458
|
+
let getConnectedPsqlExec = {
|
|
459
|
+
let pgDockerServiceName = "envio-postgres"
|
|
460
|
+
// Should use the default port, since we're executing the command
|
|
461
|
+
// from the postgres container's network.
|
|
462
|
+
let pgDockerServicePort = 5432
|
|
463
|
+
|
|
464
|
+
// For development: We run the indexer process locally,
|
|
465
|
+
// and there might not be psql installed on the user's machine.
|
|
466
|
+
// So we use docker-compose to run psql existing in the postgres container.
|
|
467
|
+
// For production: We expect indexer to be running in a container,
|
|
468
|
+
// with psql installed. So we can call it directly.
|
|
469
|
+
let psqlExecState = ref(Unknown)
|
|
470
|
+
async (~pgUser, ~pgHost, ~pgDatabase, ~pgPort) => {
|
|
471
|
+
switch psqlExecState.contents {
|
|
472
|
+
| Unknown => {
|
|
473
|
+
let promise = Promise.make((resolve, _reject) => {
|
|
474
|
+
let binary = "psql"
|
|
475
|
+
NodeJs.ChildProcess.exec(`${binary} --version`, (~error, ~stdout as _, ~stderr as _) => {
|
|
476
|
+
switch error {
|
|
477
|
+
| Value(_) => {
|
|
478
|
+
let binary = `docker-compose exec -T -u ${pgUser} ${pgDockerServiceName} psql`
|
|
479
|
+
NodeJs.ChildProcess.exec(
|
|
480
|
+
`${binary} --version`,
|
|
481
|
+
(~error, ~stdout as _, ~stderr as _) => {
|
|
482
|
+
switch error {
|
|
483
|
+
| Value(_) =>
|
|
484
|
+
resolve(
|
|
485
|
+
Error(`Please check if "psql" binary is installed or docker-compose is running for the local indexer.`),
|
|
486
|
+
)
|
|
487
|
+
| Null =>
|
|
488
|
+
resolve(
|
|
489
|
+
Ok(
|
|
490
|
+
`${binary} -h ${pgHost} -p ${pgDockerServicePort->Js.Int.toString} -U ${pgUser} -d ${pgDatabase}`,
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
| Null =>
|
|
498
|
+
resolve(
|
|
499
|
+
Ok(
|
|
500
|
+
`${binary} -h ${pgHost} -p ${pgPort->Js.Int.toString} -U ${pgUser} -d ${pgDatabase}`,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
psqlExecState := Pending(promise)
|
|
508
|
+
let result = await promise
|
|
509
|
+
psqlExecState := Resolved(result)
|
|
510
|
+
result
|
|
511
|
+
}
|
|
512
|
+
| Pending(promise) => await promise
|
|
513
|
+
| Resolved(result) => result
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let make = (
|
|
519
|
+
~sql: Postgres.sql,
|
|
520
|
+
~pgHost,
|
|
521
|
+
~pgSchema,
|
|
522
|
+
~pgPort,
|
|
523
|
+
~pgUser,
|
|
524
|
+
~pgDatabase,
|
|
525
|
+
~pgPassword,
|
|
526
|
+
~onInitialize=?,
|
|
527
|
+
~onNewTables=?,
|
|
528
|
+
): Persistence.storage => {
|
|
529
|
+
let psqlExecOptions: NodeJs.ChildProcess.execOptions = {
|
|
530
|
+
env: Js.Dict.fromArray([("PGPASSWORD", pgPassword), ("PATH", %raw(`process.env.PATH`))]),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let cacheDirPath = NodeJs.Path.resolve([
|
|
534
|
+
// Right outside of the generated directory
|
|
535
|
+
"..",
|
|
536
|
+
".envio",
|
|
537
|
+
"cache",
|
|
538
|
+
])
|
|
539
|
+
|
|
379
540
|
let isInitialized = async () => {
|
|
380
|
-
let
|
|
541
|
+
let envioTables =
|
|
381
542
|
await sql->Postgres.unsafe(
|
|
382
|
-
`SELECT
|
|
543
|
+
`SELECT table_schema FROM information_schema.tables WHERE table_schema = '${pgSchema}' AND table_name = '${eventSyncStateTableName}';`,
|
|
383
544
|
)
|
|
384
|
-
|
|
545
|
+
envioTables->Utils.Array.notEmpty
|
|
385
546
|
}
|
|
386
547
|
|
|
387
|
-
let initialize = async (~entities=[], ~generalTables=[], ~enums=[]
|
|
548
|
+
let initialize = async (~entities=[], ~generalTables=[], ~enums=[]) => {
|
|
549
|
+
let schemaTableNames: array<schemaTableName> =
|
|
550
|
+
await sql->Postgres.unsafe(makeSchemaTableNamesQuery(~pgSchema))
|
|
551
|
+
|
|
552
|
+
// The initialization query will completely drop the schema and recreate it from scratch.
|
|
553
|
+
// So we need to check if the schema is not used for anything else than envio.
|
|
554
|
+
if (
|
|
555
|
+
// Should pass with existing schema with no tables
|
|
556
|
+
// This might happen when used with public schema
|
|
557
|
+
// which is automatically created by postgres.
|
|
558
|
+
schemaTableNames->Utils.Array.notEmpty &&
|
|
559
|
+
// Otherwise should throw if there's a table, but no envio specific one
|
|
560
|
+
// This means that the schema is used for something else than envio.
|
|
561
|
+
!(schemaTableNames->Js.Array2.some(table => table.tableName === eventSyncStateTableName))
|
|
562
|
+
) {
|
|
563
|
+
Js.Exn.raiseError(
|
|
564
|
+
`Cannot run Envio migrations on PostgreSQL schema "${pgSchema}" because it contains non-Envio tables. Running migrations would delete all data in this schema.\n\nTo resolve this:\n1. If you want to use this schema, first backup any important data, then drop it with: "pnpm envio local db-migrate down"\n2. Or specify a different schema name by setting the "ENVIO_PG_PUBLIC_SCHEMA" environment variable\n3. Or manually drop the schema in your database if you're certain the data is not needed.`,
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
388
568
|
let queries = makeInitializeTransaction(
|
|
389
569
|
~pgSchema,
|
|
390
570
|
~pgUser,
|
|
391
571
|
~generalTables,
|
|
392
572
|
~entities,
|
|
393
573
|
~enums,
|
|
394
|
-
~
|
|
574
|
+
~isEmptyPgSchema=schemaTableNames->Utils.Array.isEmpty,
|
|
395
575
|
)
|
|
396
576
|
// Execute all queries within a single transaction for integrity
|
|
397
577
|
let _ = await sql->Postgres.beginSql(sql => {
|
|
398
578
|
queries->Js.Array2.map(query => sql->Postgres.unsafe(query))
|
|
399
579
|
})
|
|
580
|
+
|
|
581
|
+
switch onInitialize {
|
|
582
|
+
| Some(onInitialize) => await onInitialize()
|
|
583
|
+
| None => ()
|
|
584
|
+
}
|
|
400
585
|
}
|
|
401
586
|
|
|
402
587
|
let loadByIdsOrThrow = async (~ids, ~table: Table.table, ~rowsSchema) => {
|
|
@@ -404,12 +589,12 @@ let make = (~sql: Postgres.sql, ~pgSchema, ~pgUser): Persistence.storage => {
|
|
|
404
589
|
switch ids {
|
|
405
590
|
| [_] =>
|
|
406
591
|
sql->Postgres.preparedUnsafe(
|
|
407
|
-
|
|
592
|
+
makeLoadByIdQuery(~pgSchema, ~tableName=table.tableName),
|
|
408
593
|
ids->Obj.magic,
|
|
409
594
|
)
|
|
410
595
|
| _ =>
|
|
411
596
|
sql->Postgres.preparedUnsafe(
|
|
412
|
-
|
|
597
|
+
makeLoadByIdsQuery(~pgSchema, ~tableName=table.tableName),
|
|
413
598
|
[ids]->Obj.magic,
|
|
414
599
|
)
|
|
415
600
|
}
|
|
@@ -434,6 +619,52 @@ let make = (~sql: Postgres.sql, ~pgSchema, ~pgUser): Persistence.storage => {
|
|
|
434
619
|
}
|
|
435
620
|
}
|
|
436
621
|
|
|
622
|
+
let loadByFieldOrThrow = async (
|
|
623
|
+
~fieldName: string,
|
|
624
|
+
~fieldSchema,
|
|
625
|
+
~fieldValue,
|
|
626
|
+
~operator: Persistence.operator,
|
|
627
|
+
~table: Table.table,
|
|
628
|
+
~rowsSchema,
|
|
629
|
+
) => {
|
|
630
|
+
let params = try [fieldValue->S.reverseConvertToJsonOrThrow(fieldSchema)]->Obj.magic catch {
|
|
631
|
+
| exn =>
|
|
632
|
+
raise(
|
|
633
|
+
Persistence.StorageError({
|
|
634
|
+
message: `Failed loading "${table.tableName}" from storage by field "${fieldName}". Couldn't serialize provided value.`,
|
|
635
|
+
reason: exn,
|
|
636
|
+
}),
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
switch await sql->Postgres.preparedUnsafe(
|
|
640
|
+
makeLoadByFieldQuery(
|
|
641
|
+
~pgSchema,
|
|
642
|
+
~tableName=table.tableName,
|
|
643
|
+
~fieldName,
|
|
644
|
+
~operator=(operator :> string),
|
|
645
|
+
),
|
|
646
|
+
params,
|
|
647
|
+
) {
|
|
648
|
+
| exception exn =>
|
|
649
|
+
raise(
|
|
650
|
+
Persistence.StorageError({
|
|
651
|
+
message: `Failed loading "${table.tableName}" from storage by field "${fieldName}"`,
|
|
652
|
+
reason: exn,
|
|
653
|
+
}),
|
|
654
|
+
)
|
|
655
|
+
| rows =>
|
|
656
|
+
try rows->S.parseOrThrow(rowsSchema) catch {
|
|
657
|
+
| exn =>
|
|
658
|
+
raise(
|
|
659
|
+
Persistence.StorageError({
|
|
660
|
+
message: `Failed to parse "${table.tableName}" loaded from storage by ids`,
|
|
661
|
+
reason: exn,
|
|
662
|
+
}),
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
437
668
|
let setOrThrow = (
|
|
438
669
|
type item,
|
|
439
670
|
~items: array<item>,
|
|
@@ -449,10 +680,212 @@ let make = (~sql: Postgres.sql, ~pgSchema, ~pgUser): Persistence.storage => {
|
|
|
449
680
|
)
|
|
450
681
|
}
|
|
451
682
|
|
|
683
|
+
let setEffectCacheOrThrow = async (
|
|
684
|
+
~effectName: string,
|
|
685
|
+
~ids: array<string>,
|
|
686
|
+
~outputs: array<Internal.effectOutput>,
|
|
687
|
+
~outputSchema: S.t<Internal.effectOutput>,
|
|
688
|
+
~initialize: bool,
|
|
689
|
+
) => {
|
|
690
|
+
let table = Table.mkTable(
|
|
691
|
+
cacheTablePrefix ++ effectName,
|
|
692
|
+
~fields=[
|
|
693
|
+
Table.mkField("id", Text, ~fieldSchema=S.string, ~isPrimaryKey=true),
|
|
694
|
+
Table.mkField("output", JsonB, ~fieldSchema=S.json(~validate=false)),
|
|
695
|
+
],
|
|
696
|
+
~compositeIndices=[],
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if initialize {
|
|
700
|
+
let _ = await sql->Postgres.unsafe(makeCreateTableQuery(table, ~pgSchema))
|
|
701
|
+
switch onNewTables {
|
|
702
|
+
| Some(onNewTables) => await onNewTables(~tableNames=[table.tableName])
|
|
703
|
+
| None => ()
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
let items = []
|
|
708
|
+
for idx in 0 to outputs->Array.length - 1 {
|
|
709
|
+
items
|
|
710
|
+
->Js.Array2.push({
|
|
711
|
+
"id": ids[idx],
|
|
712
|
+
"output": outputs[idx],
|
|
713
|
+
})
|
|
714
|
+
->ignore
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await setOrThrow(
|
|
718
|
+
~items,
|
|
719
|
+
~table,
|
|
720
|
+
~itemSchema=S.schema(s =>
|
|
721
|
+
{
|
|
722
|
+
"id": s.matches(S.string),
|
|
723
|
+
"output": s.matches(outputSchema),
|
|
724
|
+
}
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let dumpEffectCache = async () => {
|
|
730
|
+
try {
|
|
731
|
+
let cacheTableInfo: array<schemaCacheTableInfo> =
|
|
732
|
+
(await sql
|
|
733
|
+
->Postgres.unsafe(makeSchemaCacheTableInfoQuery(~pgSchema)))
|
|
734
|
+
->Js.Array2.filter(i => i.count > 0)
|
|
735
|
+
|
|
736
|
+
if cacheTableInfo->Utils.Array.notEmpty {
|
|
737
|
+
// Create .envio/cache directory if it doesn't exist
|
|
738
|
+
try {
|
|
739
|
+
await NodeJs.Fs.Promises.access(cacheDirPath)
|
|
740
|
+
} catch {
|
|
741
|
+
| _ =>
|
|
742
|
+
// Create directory if it doesn't exist
|
|
743
|
+
await NodeJs.Fs.Promises.mkdir(~path=cacheDirPath, ~options={recursive: true})
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Command for testing. Run from generated
|
|
747
|
+
// docker-compose exec -T -u postgres envio-postgres psql -d envio-dev -c 'COPY "public"."envio_effect_getTokenMetadata" TO STDOUT (FORMAT text, HEADER);' > ../.envio/cache/getTokenMetadata.tsv
|
|
748
|
+
|
|
749
|
+
switch await getConnectedPsqlExec(~pgUser, ~pgHost, ~pgDatabase, ~pgPort) {
|
|
750
|
+
| Ok(psqlExec) => {
|
|
751
|
+
Logging.info(
|
|
752
|
+
`Dumping cache: ${cacheTableInfo
|
|
753
|
+
->Js.Array2.map(({tableName, count}) =>
|
|
754
|
+
tableName ++ " (" ++ count->Belt.Int.toString ++ " rows)"
|
|
755
|
+
)
|
|
756
|
+
->Js.Array2.joinWith(", ")}`,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
let promises = cacheTableInfo->Js.Array2.map(async ({tableName}) => {
|
|
760
|
+
let cacheName = tableName->Js.String2.sliceToEnd(~from=cacheTablePrefixLength)
|
|
761
|
+
let outputFile =
|
|
762
|
+
NodeJs.Path.join(cacheDirPath, cacheName ++ ".tsv")->NodeJs.Path.toString
|
|
763
|
+
|
|
764
|
+
let command = `${psqlExec} -c 'COPY "${pgSchema}"."${tableName}" TO STDOUT WITH (FORMAT text, HEADER);' > ${outputFile}`
|
|
765
|
+
|
|
766
|
+
Promise.make((resolve, reject) => {
|
|
767
|
+
NodeJs.ChildProcess.execWithOptions(
|
|
768
|
+
command,
|
|
769
|
+
psqlExecOptions,
|
|
770
|
+
(~error, ~stdout, ~stderr as _) => {
|
|
771
|
+
switch error {
|
|
772
|
+
| Value(error) => reject(error)
|
|
773
|
+
| Null => resolve(stdout)
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
)
|
|
777
|
+
})
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
let _ = await promises->Promise.all
|
|
781
|
+
Logging.info(`Successfully dumped cache to ${cacheDirPath->NodeJs.Path.toString}`)
|
|
782
|
+
}
|
|
783
|
+
| Error(message) => Logging.error(`Failed to dump cache. ${message}`)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
| exn => Logging.errorWithExn(exn->Internal.prettifyExn, `Failed to dump cache.`)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
let restoreEffectCache = async (~withUpload) => {
|
|
792
|
+
if withUpload {
|
|
793
|
+
// Try to restore cache tables from binary files
|
|
794
|
+
let nothingToUploadErrorMessage = "Nothing to upload."
|
|
795
|
+
|
|
796
|
+
switch await Promise.all2((
|
|
797
|
+
NodeJs.Fs.Promises.readdir(cacheDirPath)
|
|
798
|
+
->Promise.thenResolve(e => Ok(e))
|
|
799
|
+
->Promise.catch(_ => Promise.resolve(Error(nothingToUploadErrorMessage))),
|
|
800
|
+
getConnectedPsqlExec(~pgUser, ~pgHost, ~pgDatabase, ~pgPort),
|
|
801
|
+
)) {
|
|
802
|
+
| (Ok(entries), Ok(psqlExec)) => {
|
|
803
|
+
let cacheFiles = entries->Js.Array2.filter(entry => {
|
|
804
|
+
entry->Js.String2.endsWith(".tsv")
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
let _ =
|
|
808
|
+
await cacheFiles
|
|
809
|
+
->Js.Array2.map(entry => {
|
|
810
|
+
let cacheName = entry->Js.String2.slice(~from=0, ~to_=-4) // Remove .tsv extension
|
|
811
|
+
let tableName = cacheTablePrefix ++ cacheName
|
|
812
|
+
let table = Table.mkTable(
|
|
813
|
+
tableName,
|
|
814
|
+
~fields=[
|
|
815
|
+
Table.mkField("id", Text, ~fieldSchema=S.string, ~isPrimaryKey=true),
|
|
816
|
+
Table.mkField("output", JsonB, ~fieldSchema=S.json(~validate=false)),
|
|
817
|
+
],
|
|
818
|
+
~compositeIndices=[],
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
sql
|
|
822
|
+
->Postgres.unsafe(makeCreateTableQuery(table, ~pgSchema))
|
|
823
|
+
->Promise.then(() => {
|
|
824
|
+
let inputFile = NodeJs.Path.join(cacheDirPath, entry)->NodeJs.Path.toString
|
|
825
|
+
|
|
826
|
+
let command = `${psqlExec} -c 'COPY "${pgSchema}"."${tableName}" FROM STDIN WITH (FORMAT text, HEADER);' < ${inputFile}`
|
|
827
|
+
|
|
828
|
+
Promise.make(
|
|
829
|
+
(resolve, reject) => {
|
|
830
|
+
NodeJs.ChildProcess.execWithOptions(
|
|
831
|
+
command,
|
|
832
|
+
psqlExecOptions,
|
|
833
|
+
(~error, ~stdout, ~stderr as _) => {
|
|
834
|
+
switch error {
|
|
835
|
+
| Value(error) => reject(error)
|
|
836
|
+
| Null => resolve(stdout)
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
)
|
|
840
|
+
},
|
|
841
|
+
)
|
|
842
|
+
})
|
|
843
|
+
})
|
|
844
|
+
->Promise.all
|
|
845
|
+
|
|
846
|
+
Logging.info("Successfully uploaded cache.")
|
|
847
|
+
}
|
|
848
|
+
| (Error(message), _)
|
|
849
|
+
| (_, Error(message)) =>
|
|
850
|
+
if message === nothingToUploadErrorMessage {
|
|
851
|
+
Logging.info("No cache found to upload.")
|
|
852
|
+
} else {
|
|
853
|
+
Logging.error(`Failed to upload cache, continuing without it. ${message}`)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
let cacheTableInfo: array<schemaCacheTableInfo> =
|
|
859
|
+
await sql->Postgres.unsafe(makeSchemaCacheTableInfoQuery(~pgSchema))
|
|
860
|
+
|
|
861
|
+
if withUpload && cacheTableInfo->Utils.Array.notEmpty {
|
|
862
|
+
switch onNewTables {
|
|
863
|
+
| Some(onNewTables) =>
|
|
864
|
+
await onNewTables(
|
|
865
|
+
~tableNames=cacheTableInfo->Js.Array2.map(info => {
|
|
866
|
+
info.tableName
|
|
867
|
+
}),
|
|
868
|
+
)
|
|
869
|
+
| None => ()
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
cacheTableInfo->Js.Array2.map((info): Persistence.effectCacheRecord => {
|
|
874
|
+
{
|
|
875
|
+
effectName: info.tableName->Js.String2.sliceToEnd(~from=cacheTablePrefixLength),
|
|
876
|
+
count: info.count,
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
452
881
|
{
|
|
453
882
|
isInitialized,
|
|
454
883
|
initialize,
|
|
884
|
+
loadByFieldOrThrow,
|
|
455
885
|
loadByIdsOrThrow,
|
|
456
886
|
setOrThrow,
|
|
887
|
+
setEffectCacheOrThrow,
|
|
888
|
+
dumpEffectCache,
|
|
889
|
+
restoreEffectCache,
|
|
457
890
|
}
|
|
458
891
|
}
|