@effect-app/infra 4.0.0-beta.81 → 4.0.0-beta.83

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.
@@ -0,0 +1,332 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import { Effect, type NonEmptyReadonlyArray, Option, Struct } from "effect-app"
4
+ import { toNonEmptyArray } from "effect-app/Array"
5
+ import { SqlClient } from "effect/unstable/sql"
6
+ import { OptimisticConcurrencyException } from "../errors.js"
7
+ import { InfraLogger } from "../logger.js"
8
+ import type { FieldValues } from "../Model/filter/types.js"
9
+ import { storeId } from "./Memory.js"
10
+ import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, StoreMaker } from "./service.js"
11
+ import { buildWhereSQLQuery, logQuery, type SQLDialect, sqliteDialect } from "./SQL/query.js"
12
+ import { makeETag } from "./utils.js"
13
+
14
+ /** @internal */
15
+ export const parseRow = <Encoded extends FieldValues>(
16
+ row: { id: string; _etag: string | null; data: string },
17
+ idKey: PropertyKey,
18
+ defaultValues: Partial<Encoded>
19
+ ): PersistenceModelType<Encoded> => {
20
+ const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
21
+ return { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
22
+ }
23
+
24
+ const parseSelectRow = <Encoded extends FieldValues>(
25
+ row: Record<string, unknown>,
26
+ idKey: PropertyKey,
27
+ defaultValues: Partial<Encoded>
28
+ ): any => {
29
+ const result: Record<string, unknown> = { ...defaultValues }
30
+ for (const [key, value] of Object.entries(row)) {
31
+ if (key === "id") {
32
+ result[idKey as string] = value
33
+ result["id"] = value
34
+ } else if (typeof value === "string") {
35
+ try {
36
+ result[key] = JSON.parse(value)
37
+ } catch {
38
+ result[key] = value
39
+ }
40
+ } else {
41
+ result[key] = value
42
+ }
43
+ }
44
+ return result
45
+ }
46
+
47
+ function makeSQLStoreInt(dialect: SQLDialect, jsonColumnType: string) {
48
+ return ({ prefix }: StorageConfig) =>
49
+ Effect.gen(function*() {
50
+ const sql = yield* SqlClient.SqlClient
51
+ return {
52
+ make: <IdKey extends keyof Encoded, Encoded extends FieldValues, R = never, E = never>(
53
+ name: string,
54
+ idKey: IdKey,
55
+ seed?: Effect.Effect<Iterable<Encoded>, E, R>,
56
+ config?: StoreConfig<Encoded>
57
+ ) =>
58
+ Effect.gen(function*() {
59
+ type PM = PersistenceModelType<Encoded>
60
+ const tableName = `${prefix}${name}`
61
+ const defaultValues = config?.defaultValues ?? {}
62
+
63
+ const resolveNamespace = !config?.allowNamespace
64
+ ? Effect.succeed("primary")
65
+ : storeId.asEffect().pipe(Effect.map((namespace) => {
66
+ if (namespace !== "primary" && !config.allowNamespace!(namespace)) {
67
+ throw new Error(`Namespace ${namespace} not allowed!`)
68
+ }
69
+ return namespace
70
+ }))
71
+
72
+ yield* sql
73
+ .unsafe(
74
+ `CREATE TABLE IF NOT EXISTS "${tableName}" (id TEXT NOT NULL, _namespace TEXT NOT NULL DEFAULT 'primary', _etag TEXT, data ${jsonColumnType} NOT NULL, PRIMARY KEY (id, _namespace))`
75
+ )
76
+ .pipe(Effect.orDie)
77
+
78
+ const toRow = (e: PM) => {
79
+ const newE = makeETag(e)
80
+ const id = newE[idKey] as string
81
+ const { _etag, [idKey]: _id, ...rest } = newE as any
82
+ const data = JSON.stringify(rest)
83
+ return { id, _etag: newE._etag!, data, item: newE }
84
+ }
85
+
86
+ const exec = (query: string, params?: readonly unknown[]) =>
87
+ sql.unsafe(query, params as any).pipe(Effect.orDie)
88
+
89
+ const s: Store<IdKey, Encoded> = {
90
+ all: resolveNamespace.pipe(Effect.flatMap((ns) =>
91
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = ?`, [ns])
92
+ .pipe(
93
+ Effect.map((rows) => (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues))),
94
+ Effect.withSpan("SQL.all [effect-app/infra/Store]", {
95
+ attributes: {
96
+ "repository.table_name": tableName,
97
+ "repository.model_name": name,
98
+ "repository.namespace": ns
99
+ }
100
+ }, { captureStackTrace: false })
101
+ )
102
+ )),
103
+
104
+ find: (id) =>
105
+ resolveNamespace.pipe(Effect.flatMap((ns) =>
106
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE id = ? AND _namespace = ?`, [id, ns])
107
+ .pipe(
108
+ Effect.map((rows) => {
109
+ const row = (rows as any[])[0]
110
+ return row
111
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues))
112
+ : Option.none()
113
+ }),
114
+ Effect.withSpan("SQL.find [effect-app/infra/Store]", {
115
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id }
116
+ }, { captureStackTrace: false })
117
+ )
118
+ )),
119
+
120
+ filter: <U extends keyof Encoded = never>(f: FilterArgs<Encoded, U>) => {
121
+ const filter = f
122
+ .filter
123
+ type M = U extends undefined ? Encoded
124
+ : Pick<Encoded, U>
125
+ return resolveNamespace
126
+ .pipe(Effect
127
+ .flatMap((ns) =>
128
+ Effect
129
+ .sync(() => {
130
+ const q = buildWhereSQLQuery(
131
+ dialect,
132
+ idKey,
133
+ filter ? [{ t: "where-scope", result: filter, relation: "some" }] : [],
134
+ tableName,
135
+ defaultValues,
136
+ f
137
+ .select as
138
+ | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
139
+ | undefined,
140
+ f
141
+ .order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
142
+ f
143
+ .skip,
144
+ f
145
+ .limit
146
+ )
147
+ const nsPlaceholder = dialect
148
+ .placeholder(
149
+ q
150
+ .params
151
+ .length + 1
152
+ )
153
+ const hasWhere = q
154
+ .sql
155
+ .includes("WHERE")
156
+ const nsSql = hasWhere
157
+ ? q
158
+ .sql
159
+ .replace("WHERE", `WHERE _namespace = ${nsPlaceholder} AND`)
160
+ : q
161
+ .sql
162
+ .replace(
163
+ `FROM "${tableName}"`,
164
+ `FROM "${tableName}" WHERE _namespace = ${nsPlaceholder}`
165
+ )
166
+ return {
167
+ sql: nsSql,
168
+ params: [
169
+ ...q
170
+ .params,
171
+ ns
172
+ ]
173
+ }
174
+ })
175
+ .pipe(
176
+ Effect
177
+ .tap((q) =>
178
+ logQuery(q)
179
+ ),
180
+ Effect.flatMap((q) =>
181
+ exec(q.sql, q.params).pipe(
182
+ Effect.map((rows) => {
183
+ if (f.select) {
184
+ return (rows as any[]).map((r) => {
185
+ const selected = parseSelectRow(r, idKey, {})
186
+ return {
187
+ ...Struct.pick(
188
+ defaultValues as any,
189
+ f.select!.filter((_) => typeof _ === "string") as never[]
190
+ ),
191
+ ...selected
192
+ } as M
193
+ })
194
+ }
195
+ return (rows as any[]).map((r) =>
196
+ parseRow<Encoded>(r, idKey, defaultValues) as any as M
197
+ )
198
+ })
199
+ )
200
+ ),
201
+ Effect.withSpan("SQL.filter [effect-app/infra/Store]", {
202
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
203
+ }, { captureStackTrace: false })
204
+ )
205
+ ))
206
+ },
207
+
208
+ set: (e) =>
209
+ resolveNamespace.pipe(Effect.flatMap((ns) =>
210
+ Effect
211
+ .gen(function*() {
212
+ const row = toRow(e)
213
+ if (e._etag) {
214
+ yield* exec(
215
+ `UPDATE "${tableName}" SET _etag = ?, data = ? WHERE id = ? AND _etag = ? AND _namespace = ?`,
216
+ [row._etag, row.data, row.id, e._etag, ns]
217
+ )
218
+ const existing = yield* exec(
219
+ `SELECT _etag FROM "${tableName}" WHERE id = ? AND _namespace = ?`,
220
+ [row.id, ns]
221
+ )
222
+ const current = (existing as any[])[0]
223
+ if (!current || current._etag !== row._etag) {
224
+ if (current) {
225
+ return yield* new OptimisticConcurrencyException({
226
+ type: name,
227
+ id: row.id,
228
+ current: current._etag,
229
+ found: e._etag,
230
+ code: 412
231
+ })
232
+ }
233
+ return yield* new OptimisticConcurrencyException({
234
+ type: name,
235
+ id: row.id,
236
+ current: "",
237
+ found: e._etag,
238
+ code: 404
239
+ })
240
+ }
241
+ } else {
242
+ yield* exec(
243
+ `INSERT INTO "${tableName}" (id, _namespace, _etag, data) VALUES (?, ?, ?, ?)`,
244
+ [row.id, ns, row._etag, row.data]
245
+ )
246
+ }
247
+ return row.item
248
+ })
249
+ .pipe(
250
+ Effect.withSpan("SQL.set [effect-app/infra/Store]", {
251
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id: e[idKey] }
252
+ }, { captureStackTrace: false })
253
+ )
254
+ )),
255
+
256
+ batchSet: (items) =>
257
+ sql
258
+ .withTransaction(
259
+ Effect.forEach(items, (e) => s.set(e))
260
+ )
261
+ .pipe(
262
+ Effect.orDie,
263
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
264
+ Effect.withSpan("SQL.batchSet [effect-app/infra/Store]", {
265
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
266
+ }, { captureStackTrace: false })
267
+ ),
268
+
269
+ bulkSet: (items) =>
270
+ sql
271
+ .withTransaction(
272
+ Effect.forEach(items, (e) => s.set(e))
273
+ )
274
+ .pipe(
275
+ Effect.orDie,
276
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
277
+ Effect.withSpan("SQL.bulkSet [effect-app/infra/Store]", {
278
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
279
+ }, { captureStackTrace: false })
280
+ ),
281
+
282
+ batchRemove: (ids) => {
283
+ const placeholders = ids.map(() => "?").join(", ")
284
+ return resolveNamespace.pipe(Effect.flatMap((ns) =>
285
+ exec(
286
+ `DELETE FROM "${tableName}" WHERE id IN (${placeholders}) AND _namespace = ?`,
287
+ [...ids, ns]
288
+ )
289
+ .pipe(
290
+ Effect.asVoid,
291
+ Effect.withSpan("SQL.batchRemove [effect-app/infra/Store]", {
292
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
293
+ }, { captureStackTrace: false })
294
+ )
295
+ ))
296
+ },
297
+
298
+ queryRaw: (query) =>
299
+ s.all.pipe(
300
+ Effect.map(query.memory),
301
+ Effect.withSpan("SQL.queryRaw [effect-app/infra/Store]", {
302
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
303
+ }, { captureStackTrace: false })
304
+ )
305
+ }
306
+
307
+ if (seed) {
308
+ const existing = yield* exec(
309
+ `SELECT COUNT(*) as cnt FROM "${tableName}" WHERE _namespace = ?`,
310
+ ["primary"]
311
+ )
312
+ const count = (existing as any[])[0]?.cnt ?? 0
313
+ if (count === 0) {
314
+ yield* InfraLogger.logInfo("Seeding data for " + name)
315
+ const items = yield* seed
316
+ yield* Effect.flatMapOption(
317
+ Effect.succeed(toNonEmptyArray([...items])),
318
+ (a) => s.bulkSet(a).pipe(Effect.orDie)
319
+ )
320
+ }
321
+ }
322
+
323
+ return s
324
+ })
325
+ }
326
+ })
327
+ }
328
+
329
+ export function SQLiteStoreLayer(cfg: StorageConfig) {
330
+ return StoreMaker
331
+ .toLayer(makeSQLStoreInt(sqliteDialect, "JSON")(cfg))
332
+ }
@@ -5,6 +5,8 @@ import { DiskStoreLayer } from "./Disk.js"
5
5
  import { MemoryStoreLive } from "./Memory.js"
6
6
  // import { RedisStoreLayer } from "./Redis.js"
7
7
  import type { StorageConfig } from "./service.js"
8
+ import { SQLiteStoreLayer } from "./SQL.js"
9
+ import { PgStoreLayer } from "./SQL/Pg.js"
8
10
 
9
11
  export function StoreMakerLayer(cfg: StorageConfig) {
10
12
  return Effect
@@ -19,6 +21,14 @@ export function StoreMakerLayer(cfg: StorageConfig) {
19
21
  console.log("Using disk store at " + dir)
20
22
  return DiskStoreLayer(cfg, dir)
21
23
  }
24
+ if (storageUrl.startsWith("sqlite://")) {
25
+ console.log("Using SQLite store")
26
+ return SQLiteStoreLayer(cfg)
27
+ }
28
+ if (storageUrl.startsWith("pg://")) {
29
+ console.log("Using PostgreSQL store")
30
+ return PgStoreLayer(cfg)
31
+ }
22
32
  // if (storageUrl.startsWith("redis://")) {
23
33
  // console.log("Using Redis store")
24
34
  // return RedisStoreLayer(cfg)
@@ -10,8 +10,8 @@ import { type RawQuery } from "../Model/query.js"
10
10
  export interface StoreConfig<E> {
11
11
  partitionValue: (e?: E) => string
12
12
  /**
13
- * Primarily used for testing, creating namespaces in the database to separate data e.g to run multiple tests in isolation within the same database
14
- * currently only supported in disk/memory. CosmosDB is TODO.
13
+ * Primarily used for testing, creating namespaces in the database to separate data e.g to run multiple tests in isolation within the same database.
14
+ * Memory/Disk use separate store instances per namespace. CosmosDB uses namespace-prefixed partition keys. SQL uses a `_namespace` column.
15
15
  */
16
16
  allowNamespace?: (namespace: string) => boolean
17
17
  /**
@@ -1,7 +1,10 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { Cause, Config, Effect, Layer, Schema } from "effect"
3
3
  import { ConfigureInterruptibilityMiddleware, DevMode, DevModeMiddleware, LoggerMiddleware, RequestCacheMiddleware } from "effect-app/middleware"
4
+ import { RpcContextMap, type RpcMiddleware } from "effect-app/rpc"
4
5
  import { pretty } from "effect-app/utils"
6
+ import { type Rpc } from "effect/unstable/rpc"
7
+ import { SqlClient } from "effect/unstable/sql"
5
8
  import { logError, reportError } from "../../../errorReporter.js"
6
9
  import { InfraLogger } from "../../../logger.js"
7
10
  import { determineMethod, isCommand } from "../utils.js"
@@ -126,3 +129,43 @@ export const DefaultGenericMiddlewaresLive = Layer.mergeAll(
126
129
  LoggerMiddlewareLive,
127
130
  DevModeMiddlewareLive
128
131
  )
132
+
133
+ /**
134
+ * Config entry for `RequestContextMap` that controls per-RPC transaction wrapping.
135
+ * Defaults to `false` (no transaction). Set `requiresTransaction: true` on a route to enable.
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * class RequestContextMap extends RpcContextMap.makeMap({
140
+ * requiresTransaction: requiresTransactionConfig,
141
+ * // ...
142
+ * }) {}
143
+ * ```
144
+ */
145
+ export const requiresTransactionConfig = RpcContextMap.makeCustom()(Schema.Never, false as boolean)
146
+
147
+ /**
148
+ * Creates the middleware Effect for SQL transaction wrapping.
149
+ * Requires `SqlClient` directly (not via serviceOption).
150
+ * Reads `requiresTransaction` from the RPC config; defaults to `false`.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * const SqlTransactionMiddlewareLive = Layer.effect(
155
+ * SqlTransactionMiddleware,
156
+ * makeSqlTransactionMiddleware(RequestContextMap)
157
+ * )
158
+ * ```
159
+ */
160
+ export const makeSqlTransactionMiddleware = (
161
+ rcm: { getConfig: (rpc: Rpc.AnyWithProps) => { readonly requiresTransaction?: boolean } }
162
+ ) =>
163
+ Effect.gen(function*() {
164
+ const sql = yield* SqlClient.SqlClient
165
+ const mw: RpcMiddleware.RpcMiddlewareV4<never, never, never> = (effect, { rpc }) => {
166
+ const { requiresTransaction } = rcm.getConfig(rpc)
167
+ if (requiresTransaction !== true) return effect
168
+ return sql.withTransaction(effect).pipe(Effect.orDie)
169
+ }
170
+ return mw
171
+ })
@@ -1,9 +1,18 @@
1
- import { Effect, Layer, Tracer } from "effect-app"
1
+ import { Effect, Layer, Option, Tracer } from "effect-app"
2
2
  import { NonEmptyString255 } from "effect-app/Schema"
3
+ import { SqlClient } from "effect/unstable/sql"
3
4
  import { LocaleRef, RequestContext, spanAttributes } from "../RequestContext.js"
4
5
  import { ContextMapContainer } from "../Store/ContextMapContainer.js"
5
6
  import { storeId } from "../Store/Memory.js"
6
7
 
8
+ const withSqlTransaction = <R, E, A>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
9
+ Effect.serviceOption(SqlClient.SqlClient).pipe(
10
+ Effect.flatMap(Option.match({
11
+ onNone: () => self,
12
+ onSome: (sql) => sql.withTransaction(self).pipe(Effect.orDie)
13
+ }))
14
+ )
15
+
7
16
  export const getRequestContext = Effect
8
17
  .all({
9
18
  span: Effect.currentSpan.pipe(Effect.orDie),
@@ -43,16 +52,25 @@ const withRequestSpan = (name = "request", options?: Tracer.SpanOptions) => <R,
43
52
  )
44
53
  )
45
54
 
55
+ export interface SetupRequestOptions {
56
+ readonly withTransaction?: boolean
57
+ }
58
+
46
59
  export const setupRequestContextFromCurrent =
47
- (name = "request", options?: Tracer.SpanOptions) => <R, E, A>(self: Effect.Effect<A, E, R>) =>
60
+ (name = "request", options?: Tracer.SpanOptions & SetupRequestOptions) => <R, E, A>(self: Effect.Effect<A, E, R>) =>
48
61
  self
49
62
  .pipe(
63
+ options?.withTransaction === true ? withSqlTransaction : (_) => _,
50
64
  withRequestSpan(name, options),
51
65
  Effect.provide(ContextMapContainer.layer, { local: true })
52
66
  )
53
67
 
54
68
  // TODO: consider integrating Effect.withParentSpan
55
- export function setupRequestContext<R, E, A>(self: Effect.Effect<A, E, R>, requestContext: RequestContext) {
69
+ export function setupRequestContext<R, E, A>(
70
+ self: Effect.Effect<A, E, R>,
71
+ requestContext: RequestContext,
72
+ options?: SetupRequestOptions
73
+ ) {
56
74
  const layer = Layer.mergeAll(
57
75
  ContextMapContainer.layer,
58
76
  Layer.succeed(LocaleRef, requestContext.locale),
@@ -60,6 +78,7 @@ export function setupRequestContext<R, E, A>(self: Effect.Effect<A, E, R>, reque
60
78
  )
61
79
  return self
62
80
  .pipe(
81
+ options?.withTransaction === true ? withSqlTransaction : (_) => _,
63
82
  withRequestSpan(requestContext.name),
64
83
  Effect.provide(layer, { local: true })
65
84
  )
@@ -69,7 +88,7 @@ export function setupRequestContextWithCustomSpan<R, E, A>(
69
88
  self: Effect.Effect<A, E, R>,
70
89
  requestContext: RequestContext,
71
90
  name: string,
72
- options?: Tracer.SpanOptions
91
+ options?: Tracer.SpanOptions & SetupRequestOptions
73
92
  ) {
74
93
  const layer = Layer.mergeAll(
75
94
  ContextMapContainer.layer,
@@ -78,6 +97,7 @@ export function setupRequestContextWithCustomSpan<R, E, A>(
78
97
  )
79
98
  return self
80
99
  .pipe(
100
+ options?.withTransaction === true ? withSqlTransaction : (_) => _,
81
101
  withRequestSpan(name, options),
82
102
  Effect.provide(layer, { local: true })
83
103
  )
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-store.test.d.ts","sourceRoot":"","sources":["../sql-store.test.ts"],"names":[],"mappings":""}