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/src/PgStorage.res CHANGED
@@ -1,16 +1,16 @@
1
- let makeCreateIndexSql = (~tableName, ~indexFields, ~pgSchema) => {
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 makeCreateTableIndicesSql = (table: Table.table, ~pgSchema) => {
7
+ let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
8
8
  open Belt
9
9
  let tableName = table.tableName
10
10
  let createIndex = indexField =>
11
- makeCreateIndexSql(~tableName, ~indexFields=[indexField], ~pgSchema)
11
+ makeCreateIndexQuery(~tableName, ~indexFields=[indexField], ~pgSchema)
12
12
  let createCompositeIndex = indexFields => {
13
- makeCreateIndexSql(~tableName, ~indexFields, ~pgSchema)
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 makeCreateTableSql = (table: Table.table, ~pgSchema) => {
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
- ~cleanRun=false,
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
- cleanRun
75
- ? `DROP SCHEMA IF EXISTS "${pgSchema}" CASCADE;
76
- CREATE SCHEMA "${pgSchema}";`
77
- : `CREATE SCHEMA IF NOT EXISTS "${pgSchema}";`
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" ++ makeCreateTableSql(table, ~pgSchema)
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 = makeCreateTableIndicesSql(table, ~pgSchema)
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
- makeCreateIndexSql(
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
- // Return optimized queries - main DDL in DO block, functions separate
144
- // Note: DO $$ BEGIN wrapper is only needed for PL/pgSQL conditionals (IF NOT EXISTS)
145
- // Reset case uses direct DDL (faster), non-cleanRun case uses conditionals (safer)
146
- cleanRun || enums->Utils.Array.isEmpty
147
- ? query.contents
148
- : `DO $$ BEGIN ${query.contents} END $$;`,
149
- // Functions query (separate as they can't be in DO block)
150
- ]->Js.Array2.concat(functionsQuery.contents !== "" ? [functionsQuery.contents] : [])
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 makeLoadByIdSql = (~pgSchema, ~tableName) => {
151
+ let makeLoadByIdQuery = (~pgSchema, ~tableName) => {
154
152
  `SELECT * FROM "${pgSchema}"."${tableName}" WHERE id = $1 LIMIT 1;`
155
153
  }
156
154
 
157
- let makeLoadByIdsSql = (~pgSchema, ~tableName) => {
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 makeInsertUnnestSetSql = (~pgSchema, ~table: Table.table, ~itemSchema, ~isRawEvents) => {
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 makeInsertValuesSetSql = (~pgSchema, ~table: Table.table, ~itemSchema, ~itemsCount) => {
192
- let {quotedFieldNames, quotedNonPrimaryFieldNames} = table->Table.toSqlParams(~schema=itemSchema)
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
- "sql": makeInsertUnnestSetSql(~pgSchema, ~table, ~itemSchema, ~isRawEvents),
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
- "sql": makeInsertValuesSetSql(~pgSchema, ~table, ~itemSchema, ~itemsCount=maxItemsPerQuery),
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 query = switch setQueryCache->Utils.WeakMap.get(table) {
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
- let payload =
318
- query["convertOrThrow"](items->(Utils.magic: array<'item> => array<unknown>))->(
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 === maxChunkSize
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
- ? sqlQuery
344
- : makeInsertValuesSetSql(
345
- ~pgSchema,
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(sqlQuery, payload->Obj.magic)
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 make = (~sql: Postgres.sql, ~pgSchema, ~pgUser): Persistence.storage => {
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 schemas =
541
+ let envioTables =
381
542
  await sql->Postgres.unsafe(
382
- `SELECT schema_name FROM information_schema.schemata WHERE schema_name = '${pgSchema}';`,
543
+ `SELECT table_schema FROM information_schema.tables WHERE table_schema = '${pgSchema}' AND table_name = '${eventSyncStateTableName}';`,
383
544
  )
384
- schemas->Utils.Array.notEmpty
545
+ envioTables->Utils.Array.notEmpty
385
546
  }
386
547
 
387
- let initialize = async (~entities=[], ~generalTables=[], ~enums=[], ~cleanRun=false) => {
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
- ~cleanRun,
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
- makeLoadByIdSql(~pgSchema, ~tableName=table.tableName),
592
+ makeLoadByIdQuery(~pgSchema, ~tableName=table.tableName),
408
593
  ids->Obj.magic,
409
594
  )
410
595
  | _ =>
411
596
  sql->Postgres.preparedUnsafe(
412
- makeLoadByIdsSql(~pgSchema, ~tableName=table.tableName),
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
  }