Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/few-oranges-project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/infra": minor
---

Add optional SQL root-level projected columns for repository-backed SQLite and PostgreSQL stores.
4 changes: 4 additions & 0 deletions packages/effect-app/src/Model/Repository/internal/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Context from "../../../Context.js"
import * as Effect from "../../../Effect.js"
import { flatMapOption } from "../../../Effect.js"
import * as Option from "../../../Option.js"
import { makeRootLevelFieldColumns } from "../../../rootLevelFields.js"
import * as S from "../../../Schema.js"
import { type Codec, NonNegativeInt } from "../../../Schema.js"
import { setupRequestContextFromCurrent } from "../../../setupRequest.ts"
Expand Down Expand Up @@ -640,6 +641,8 @@ export function makeStore<Encoded extends FieldValues>() {
mapTo: (e: E, etag: string | undefined) => Encoded,
idKey: IdKey
) => {
const rootLevelFieldColumns = makeRootLevelFieldColumns(schema, idKey)

function makeStore<RInitial = never, EInitial = never>(
makeInitial?: Effect.Effect<readonly T[], EInitial, RInitial>,
config?: Omit<StoreConfig<Encoded>, "partitionValue"> & {
Expand Down Expand Up @@ -679,6 +682,7 @@ export function makeStore<Encoded extends FieldValues>() {
: undefined,
{
...config,
rootLevelFieldColumns,
partitionValue: config?.partitionValue
?? ((_) => "primary") /*(isIntegrationEvent(r) ? r.companyId : r.id*/
}
Expand Down
8 changes: 8 additions & 0 deletions packages/effect-app/src/Model/Repository/makeRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export interface RepositoryOptions<
* use the config.defaultValues instead for simple default values
*/
jitM?: (pm: Encoded) => Encoded
/**
* Repository storage options.
*
* `rootLevelFieldsWhenAvailable` switches SQL repositories from reading/writing
* derived root fields via `data` to reading/writing those fields via dedicated
* root columns instead. Existing table/data migrations are expected to be handled
* offline rather than as runtime fallback behavior.
*/
config?: Omit<StoreConfig<Encoded>, "partitionValue"> & {
partitionValue?: (e?: Encoded) => string
}
Expand Down
24 changes: 24 additions & 0 deletions packages/effect-app/src/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,27 @@ export interface UniqueKey {
readonly paths: string[]
}

export type RootLevelFieldColumnKind = "string" | "number" | "boolean" | "json"

export interface RootLevelFieldColumn {
readonly key: string
readonly columnName: string
readonly kind: RootLevelFieldColumnKind
}

export interface StoreConfig<E> {
partitionValue: (e?: E) => string
/**
* For SQL adapters, switch storage/querying for derived root-level fields from the
* document `data` column to dedicated root columns.
*
* When disabled, reads/writes/queries use `data`.
* When enabled, derived root fields are read/written/queried via dedicated columns
* instead. Schema/data migrations for existing tables are expected to happen
* offline rather than on the fly.
*/
rootLevelFieldsWhenAvailable?: boolean
rootLevelFieldColumns?: readonly RootLevelFieldColumn[]
/**
* Primarily used for testing, creating namespaces in the database to separate data e.g to run multiple tests in isolation within the same database.
* Memory/Disk use separate store instances per namespace. CosmosDB uses namespace-prefixed partition keys. SQL uses a `_namespace` column.
Expand Down Expand Up @@ -274,4 +293,9 @@ export interface StorageConfig {
url: Redacted.Redacted
prefix: string
dbName: string
/**
* Adapter default for switching derived root-level fields from the `data` column
* to dedicated SQL columns. Repositories can still opt in / out per table.
*/
rootLevelFieldsWhenAvailable?: boolean
}
63 changes: 63 additions & 0 deletions packages/effect-app/src/rootLevelFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as SchemaAST from "effect/SchemaAST"
import type * as S from "./Schema.js"
import type { RootLevelFieldColumn, RootLevelFieldColumnKind } from "./Store.js"

const walkTransformation = (ast: SchemaAST.AST): SchemaAST.AST => {
if (ast._tag === "Declaration" && ast.typeParameters.length > 0) {
return walkTransformation(ast.typeParameters[0]!)
}
return ast
}

const getColumnKind = (ast: SchemaAST.AST): RootLevelFieldColumnKind => {
const encoded = walkTransformation(SchemaAST.toEncoded(ast))
switch (encoded._tag) {
case "String":
return "string"
case "Number":
return "number"
case "Boolean":
return "boolean"
case "Literal":
switch (typeof encoded.literal) {
case "string":
return "string"
case "number":
return "number"
case "boolean":
return "boolean"
default:
return "json"
}
case "Union": {
const scalarKinds = encoded.types.flatMap((type) => {
const kind = getColumnKind(type)
return kind === "json" ? [] : [kind]
})
const distinctKinds = [...new Set(scalarKinds)]
return distinctKinds.length === 1 ? distinctKinds[0]! : "json"
}
default:
return "json"
}
}

const makeColumnName = (key: string) => `__root_${key}`

export const makeRootLevelFieldColumns = (
schema: S.Schema<unknown>,
idKey: PropertyKey
): readonly RootLevelFieldColumn[] => {
const encoded = walkTransformation(SchemaAST.toEncoded(schema.ast))
if (!SchemaAST.isObjects(encoded)) {
return []
}

return encoded.propertySignatures.flatMap((property) => {
const key = String(property.name)
if (key === String(idKey) || key === "id" || key === "_etag") {
return []
}
return [{ key, kind: getColumnKind(property.type), columnName: makeColumnName(key) }]
})
}
Loading
Loading