@entifix/sql 0.9.0 → 0.10.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.
@@ -1,5 +1,5 @@
1
1
  import type { EntityRepository } from '@entifix/business';
2
- import { type EntifixError, type Entity, type EntityConstructor } from '@entifix/core';
2
+ import { type Entity, type EntityConstructor } from '@entifix/core';
3
3
  import type { SqlClient } from 'effect/sql';
4
4
  /**
5
5
  * Builds an {@link EntityRepository} for a single entity type, backed by an
@@ -28,5 +28,4 @@ import type { SqlClient } from 'effect/sql';
28
28
  * mapping and a second one would fight it.
29
29
  */
30
30
  export declare function makeSqlRepository<TEntity extends Entity>(sql: SqlClient.SqlClient, entityConstructor: EntityConstructor<TEntity>): EntityRepository;
31
- export type { EntifixError };
32
31
  //# sourceMappingURL=make-sql-repository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"make-sql-repository.d.ts","sourceRoot":"","sources":["../../src/adapters/make-sql-repository.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,MAAM,EACX,KAAK,iBAAiB,EAMvB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAuB5C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAS,MAAM,EACtD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,GAC5C,gBAAgB,CAsJlB;AAED,YAAY,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"make-sql-repository.d.ts","sourceRoot":"","sources":["../../src/adapters/make-sql-repository.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAQvB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAwB5C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAS,MAAM,EACtD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,GAC5C,gBAAgB,CAgIlB"}
@@ -1,7 +1,8 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
2
  import { randomUUID } from 'node:crypto';
3
- import { deserializeEntityCollection, deserializeSingleEntity, EntifixConnError, extractMetaEntity, serializeEntity } from '@entifix/core';
3
+ import { deserializeEntityCollection, deserializeSingleEntity, extractMetaEntity, InvalidQuery, NotFound, serializeEntity } from '@entifix/core';
4
4
  import { Effect } from 'effect';
5
+ import { translateSqlError } from '../errors/translate-sql-error.js';
5
6
  import { translateFiltering, translateSorting } from './sql-filter-translator.js';
6
7
  function tableName(entityConstructor) {
7
8
  var _metaEntity_key;
@@ -38,9 +39,6 @@ function toEntityId(entityOrId) {
38
39
  * mapping and a second one would fight it.
39
40
  */ export function makeSqlRepository(sql, entityConstructor) {
40
41
  const table = sql(tableName(entityConstructor));
41
- const fail = (message, error, details)=>new EntifixConnError(message, error, _extends({
42
- entity: entityConstructor.name
43
- }, details));
44
42
  const load = (request)=>Effect.gen(function*() {
45
43
  var _request_page, _request_pageSize, _ref;
46
44
  var _counted_;
@@ -52,7 +50,11 @@ function toEntityId(entityOrId) {
52
50
  where: translateFiltering(sql, entityConstructor, request.filtering),
53
51
  orderBy: translateSorting(sql, entityConstructor, request.sorting)
54
52
  }),
55
- catch: (error)=>error
53
+ // The translator refuses a member the entity did not declare, by
54
+ // throwing: that is the caller's query, answered as a 400.
55
+ catch: (error)=>new InvalidQuery({
56
+ reason: error.message
57
+ })
56
58
  });
57
59
  const where = clauses.where === undefined ? sql.literal('') : sql`WHERE ${clauses.where}`;
58
60
  const orderBy = clauses.orderBy === undefined ? sql.literal('') : sql`ORDER BY ${clauses.orderBy}`;
@@ -61,8 +63,8 @@ function toEntityId(entityOrId) {
61
63
  const offset = (page - 1) * pageSize;
62
64
  const rows = yield* sql`
63
65
  SELECT * FROM ${table} ${where} ${orderBy} LIMIT ${pageSize} OFFSET ${offset}
64
- `.pipe(Effect.mapError((error)=>fail('Failed to load entities from SQL', error)));
65
- const counted = yield* sql`SELECT COUNT(*)::int AS count FROM ${table} ${where}`.pipe(Effect.mapError((error)=>fail('Failed to count entities in SQL', error)));
66
+ `.pipe(Effect.mapError((error)=>translateSqlError(error)));
67
+ const counted = yield* sql`SELECT COUNT(*)::int AS count FROM ${table} ${where}`.pipe(Effect.mapError((error)=>translateSqlError(error)));
66
68
  const items = yield* deserializeEntityCollection(entityConstructor, [
67
69
  ...rows
68
70
  ]);
@@ -76,13 +78,11 @@ function toEntityId(entityOrId) {
76
78
  var _rows_;
77
79
  const rows = yield* sql`
78
80
  SELECT * FROM ${table} WHERE ${sql('id')} = ${id} LIMIT 1
79
- `.pipe(Effect.mapError((error)=>fail('Failed to read entity from SQL', error, {
80
- id
81
- })));
81
+ `.pipe(Effect.mapError((error)=>translateSqlError(error)));
82
82
  const entity = yield* deserializeSingleEntity(entityConstructor, (_rows_ = rows[0]) != null ? _rows_ : null);
83
83
  if (entity === undefined) {
84
- return yield* Effect.fail(fail('Entity not found', undefined, {
85
- id
84
+ return yield* Effect.fail(new NotFound({
85
+ entity: extractMetaEntity(entityConstructor).key
86
86
  }));
87
87
  }
88
88
  return entity;
@@ -105,17 +105,13 @@ function toEntityId(entityOrId) {
105
105
  yield* sql`
106
106
  INSERT INTO ${table} ${sql.insert(row)}
107
107
  ON CONFLICT (${sql('id')}) ${onConflict}
108
- `.pipe(Effect.mapError((error)=>fail('Failed to save entity to SQL', error, {
109
- id
110
- })));
108
+ `.pipe(Effect.mapError((error)=>translateSqlError(error)));
111
109
  entity.id = id;
112
110
  return entity;
113
111
  });
114
112
  const del = (entityOrId)=>Effect.gen(function*() {
115
113
  const id = toEntityId(entityOrId);
116
- yield* sql`DELETE FROM ${table} WHERE ${sql('id')} = ${id}`.pipe(Effect.mapError((error)=>fail('Failed to delete entity from SQL', error, {
117
- id
118
- })));
114
+ yield* sql`DELETE FROM ${table} WHERE ${sql('id')} = ${id}`.pipe(Effect.mapError((error)=>translateSqlError(error)));
119
115
  });
120
116
  // Cast through the interface to keep its generic signatures: each method here
121
117
  // is written against this entity's type, the interface against any entity's.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/adapters/make-sql-repository.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { EntityRepository } from '@entifix/business';\nimport {\n deserializeEntityCollection,\n deserializeSingleEntity,\n EntifixConnError,\n type EntifixError,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n type EntityPage,\n extractMetaEntity,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { SqlClient } from 'effect/sql';\n\nimport {\n translateFiltering,\n translateSorting,\n} from './sql-filter-translator.js';\n\n/** A row as the driver returns it: column names, untransformed. */\ntype SqlRow = Record<string, unknown>;\n\nfunction tableName<TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n): string {\n const metaEntity = extractMetaEntity(entityConstructor);\n return metaEntity.key ?? metaEntity.name;\n}\n\nfunction toEntityId(entityOrId: EntityId | Entity): EntityId {\n return entityOrId != null && typeof entityOrId === 'object'\n ? (entityOrId as Entity).id\n : (entityOrId as EntityId);\n}\n\n/**\n * Builds an {@link EntityRepository} for a single entity type, backed by an\n * `@effect/sql` client. The `sql` client and `entityConstructor` are captured in\n * closures, so every method's Effect has no outstanding requirements\n * (`R = never`), which is exactly what the interface asks.\n *\n * This is the relational mirror of `makeMongoRepository`, and it is short for the\n * same reason: the entifix serializer is store-agnostic. `serializeEntity` emits\n * a **flat record keyed by `alias ?? name`**, which for a scalar entity *is* a\n * table row, and the deserializer consumes the same shape coming back. So the\n * entity's own `alias` metadata is the column mapping — there is no separate\n * table/column mapping layer, and none is needed.\n *\n * Table name = the entity's `key` (falling back to its class name), the same rule\n * the Mongo adapter uses for a collection.\n *\n * **v1 handles flat scalar entities.** Embedded links and `linkCollection`\n * members have no single-column representation, so an entity carrying them needs\n * either a join-aware adapter or `alias`ed scalar foreign keys.\n *\n * Two dialect assumptions, both true of Postgres and stated so a future MySQL or\n * SQLite adapter knows what to override: `ILIKE` for case-insensitive matching,\n * and `COUNT(*)::int` for the total. The client must **not** be configured with\n * name transformation (`transformResultNames`), since `alias` is already the\n * mapping and a second one would fight it.\n */\nexport function makeSqlRepository<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n): EntityRepository {\n const table = sql(tableName(entityConstructor));\n\n const fail = (\n message: string,\n error: unknown,\n details?: Record<string, unknown>,\n ) =>\n new EntifixConnError(message, error, {\n entity: entityConstructor.name,\n ...details,\n });\n\n const load = <T extends Entity>(request: EntityLoadRequest<T>) =>\n Effect.gen(function* () {\n // The translators are framework-free and reject an undeclared member by\n // throwing, so the throw is caught back into the failure channel here —\n // the same crossing `readLoadRequest` makes for the URL codec.\n const clauses = yield* Effect.try({\n try: () => ({\n where: translateFiltering(\n sql,\n entityConstructor,\n request.filtering as EntityLoadRequest<TEntity>['filtering'],\n ),\n orderBy: translateSorting(\n sql,\n entityConstructor,\n request.sorting as EntityLoadRequest<TEntity>['sorting'],\n ),\n }),\n catch: error => error as EntifixError,\n });\n\n const where =\n clauses.where === undefined\n ? sql.literal('')\n : sql`WHERE ${clauses.where}`;\n const orderBy =\n clauses.orderBy === undefined\n ? sql.literal('')\n : sql`ORDER BY ${clauses.orderBy}`;\n\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const offset = (page - 1) * pageSize;\n\n const rows = yield* sql<SqlRow>`\n SELECT * FROM ${table} ${where} ${orderBy} LIMIT ${pageSize} OFFSET ${offset}\n `.pipe(\n Effect.mapError(error =>\n fail('Failed to load entities from SQL', error),\n ),\n );\n\n const counted = yield* sql<{\n readonly count: number;\n }>`SELECT COUNT(*)::int AS count FROM ${table} ${where}`.pipe(\n Effect.mapError(error =>\n fail('Failed to count entities in SQL', error),\n ),\n );\n\n const items = yield* deserializeEntityCollection(entityConstructor, [\n ...rows,\n ]);\n\n return {\n items: items as unknown as T[],\n total: counted[0]?.count ?? 0,\n request,\n } satisfies EntityPage<T>;\n });\n\n const get = <T extends Entity>(id: EntityId) =>\n Effect.gen(function* () {\n const rows = yield* sql<SqlRow>`\n SELECT * FROM ${table} WHERE ${sql('id')} = ${id} LIMIT 1\n `.pipe(\n Effect.mapError(error =>\n fail('Failed to read entity from SQL', error, { id }),\n ),\n );\n\n const entity = yield* deserializeSingleEntity(\n entityConstructor,\n rows[0] ?? null,\n );\n if (entity === undefined) {\n return yield* Effect.fail(fail('Entity not found', undefined, { id }));\n }\n return entity as unknown as T;\n });\n\n const save = <T extends Entity>(entity: T) =>\n Effect.gen(function* () {\n const serialized = serializeEntity(\n entityConstructor,\n entity as unknown as TEntity,\n );\n // A create arrives without an id, so the store mints one — the same\n // contract as the Mongo adapter, and what lets `save` double as the upsert\n // behind both POST and PUT.\n const id = entity.id ?? randomUUID();\n // The serializer drops `undefined`, so the resolved id is spread back in.\n const row: SqlRow = { ...serialized, id };\n\n const updatable = Object.keys(row).filter(column => column !== 'id');\n // An entity whose only member is its id has nothing to update; `DO UPDATE\n // SET` with an empty list is a syntax error, so it degrades to a no-op.\n const onConflict =\n updatable.length === 0\n ? sql.literal('DO NOTHING')\n : sql`DO UPDATE SET ${sql.csv(\n updatable.map(\n column => sql`${sql(column)} = EXCLUDED.${sql(column)}`,\n ),\n )}`;\n\n yield* sql`\n INSERT INTO ${table} ${sql.insert(row)}\n ON CONFLICT (${sql('id')}) ${onConflict}\n `.pipe(\n Effect.mapError(error =>\n fail('Failed to save entity to SQL', error, { id }),\n ),\n );\n\n entity.id = id;\n return entity;\n });\n\n const del = <T extends Entity>(entityOrId: EntityId | T) =>\n Effect.gen(function* () {\n const id = toEntityId(entityOrId);\n yield* sql`DELETE FROM ${table} WHERE ${sql('id')} = ${id}`.pipe(\n Effect.mapError(error =>\n fail('Failed to delete entity from SQL', error, { id }),\n ),\n );\n });\n\n // Cast through the interface to keep its generic signatures: each method here\n // is written against this entity's type, the interface against any entity's.\n return {\n load,\n get,\n save,\n delete: del,\n } as unknown as EntityRepository;\n}\n\nexport type { EntifixError };\n"],"names":["randomUUID","deserializeEntityCollection","deserializeSingleEntity","EntifixConnError","extractMetaEntity","serializeEntity","Effect","translateFiltering","translateSorting","tableName","entityConstructor","metaEntity","key","name","toEntityId","entityOrId","id","makeSqlRepository","sql","table","fail","message","error","details","entity","load","request","gen","counted","clauses","try","where","filtering","orderBy","sorting","catch","undefined","literal","page","pageSize","offset","rows","pipe","mapError","items","total","count","get","save","serialized","row","updatable","Object","keys","filter","column","onConflict","length","csv","map","insert","del","delete"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/adapters/make-sql-repository.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { EntityRepository } from '@entifix/business';\nimport {\n deserializeEntityCollection,\n deserializeSingleEntity,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n type EntityPage,\n extractMetaEntity,\n InvalidQuery,\n NotFound,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { SqlClient } from 'effect/sql';\n\nimport { translateSqlError } from '../errors/translate-sql-error.js';\nimport {\n translateFiltering,\n translateSorting,\n} from './sql-filter-translator.js';\n\n/** A row as the driver returns it: column names, untransformed. */\ntype SqlRow = Record<string, unknown>;\n\nfunction tableName<TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n): string {\n const metaEntity = extractMetaEntity(entityConstructor);\n return metaEntity.key ?? metaEntity.name;\n}\n\nfunction toEntityId(entityOrId: EntityId | Entity): EntityId {\n return entityOrId != null && typeof entityOrId === 'object'\n ? (entityOrId as Entity).id\n : (entityOrId as EntityId);\n}\n\n/**\n * Builds an {@link EntityRepository} for a single entity type, backed by an\n * `@effect/sql` client. The `sql` client and `entityConstructor` are captured in\n * closures, so every method's Effect has no outstanding requirements\n * (`R = never`), which is exactly what the interface asks.\n *\n * This is the relational mirror of `makeMongoRepository`, and it is short for the\n * same reason: the entifix serializer is store-agnostic. `serializeEntity` emits\n * a **flat record keyed by `alias ?? name`**, which for a scalar entity *is* a\n * table row, and the deserializer consumes the same shape coming back. So the\n * entity's own `alias` metadata is the column mapping — there is no separate\n * table/column mapping layer, and none is needed.\n *\n * Table name = the entity's `key` (falling back to its class name), the same rule\n * the Mongo adapter uses for a collection.\n *\n * **v1 handles flat scalar entities.** Embedded links and `linkCollection`\n * members have no single-column representation, so an entity carrying them needs\n * either a join-aware adapter or `alias`ed scalar foreign keys.\n *\n * Two dialect assumptions, both true of Postgres and stated so a future MySQL or\n * SQLite adapter knows what to override: `ILIKE` for case-insensitive matching,\n * and `COUNT(*)::int` for the total. The client must **not** be configured with\n * name transformation (`transformResultNames`), since `alias` is already the\n * mapping and a second one would fight it.\n */\nexport function makeSqlRepository<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n): EntityRepository {\n const table = sql(tableName(entityConstructor));\n\n const load = <T extends Entity>(request: EntityLoadRequest<T>) =>\n Effect.gen(function* () {\n // The translators are framework-free and reject an undeclared member by\n // throwing, so the throw is caught back into the failure channel here —\n // the same crossing `readLoadRequest` makes for the URL codec.\n const clauses = yield* Effect.try({\n try: () => ({\n where: translateFiltering(\n sql,\n entityConstructor,\n request.filtering as EntityLoadRequest<TEntity>['filtering'],\n ),\n orderBy: translateSorting(\n sql,\n entityConstructor,\n request.sorting as EntityLoadRequest<TEntity>['sorting'],\n ),\n }),\n // The translator refuses a member the entity did not declare, by\n // throwing: that is the caller's query, answered as a 400.\n catch: error => new InvalidQuery({ reason: (error as Error).message }),\n });\n\n const where =\n clauses.where === undefined\n ? sql.literal('')\n : sql`WHERE ${clauses.where}`;\n const orderBy =\n clauses.orderBy === undefined\n ? sql.literal('')\n : sql`ORDER BY ${clauses.orderBy}`;\n\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const offset = (page - 1) * pageSize;\n\n const rows = yield* sql<SqlRow>`\n SELECT * FROM ${table} ${where} ${orderBy} LIMIT ${pageSize} OFFSET ${offset}\n `.pipe(Effect.mapError(error => translateSqlError(error)));\n\n const counted = yield* sql<{\n readonly count: number;\n }>`SELECT COUNT(*)::int AS count FROM ${table} ${where}`.pipe(\n Effect.mapError(error => translateSqlError(error)),\n );\n\n const items = yield* deserializeEntityCollection(entityConstructor, [\n ...rows,\n ]);\n\n return {\n items: items as unknown as T[],\n total: counted[0]?.count ?? 0,\n request,\n } satisfies EntityPage<T>;\n });\n\n const get = <T extends Entity>(id: EntityId) =>\n Effect.gen(function* () {\n const rows = yield* sql<SqlRow>`\n SELECT * FROM ${table} WHERE ${sql('id')} = ${id} LIMIT 1\n `.pipe(Effect.mapError(error => translateSqlError(error)));\n\n const entity = yield* deserializeSingleEntity(\n entityConstructor,\n rows[0] ?? null,\n );\n if (entity === undefined) {\n return yield* Effect.fail(\n new NotFound({ entity: extractMetaEntity(entityConstructor).key }),\n );\n }\n return entity as unknown as T;\n });\n\n const save = <T extends Entity>(entity: T) =>\n Effect.gen(function* () {\n const serialized = serializeEntity(\n entityConstructor,\n entity as unknown as TEntity,\n );\n // A create arrives without an id, so the store mints one — the same\n // contract as the Mongo adapter, and what lets `save` double as the upsert\n // behind both POST and PUT.\n const id = entity.id ?? randomUUID();\n // The serializer drops `undefined`, so the resolved id is spread back in.\n const row: SqlRow = { ...serialized, id };\n\n const updatable = Object.keys(row).filter(column => column !== 'id');\n // An entity whose only member is its id has nothing to update; `DO UPDATE\n // SET` with an empty list is a syntax error, so it degrades to a no-op.\n const onConflict =\n updatable.length === 0\n ? sql.literal('DO NOTHING')\n : sql`DO UPDATE SET ${sql.csv(\n updatable.map(\n column => sql`${sql(column)} = EXCLUDED.${sql(column)}`,\n ),\n )}`;\n\n yield* sql`\n INSERT INTO ${table} ${sql.insert(row)}\n ON CONFLICT (${sql('id')}) ${onConflict}\n `.pipe(Effect.mapError(error => translateSqlError(error)));\n\n entity.id = id;\n return entity;\n });\n\n const del = <T extends Entity>(entityOrId: EntityId | T) =>\n Effect.gen(function* () {\n const id = toEntityId(entityOrId);\n yield* sql`DELETE FROM ${table} WHERE ${sql('id')} = ${id}`.pipe(\n Effect.mapError(error => translateSqlError(error)),\n );\n });\n\n // Cast through the interface to keep its generic signatures: each method here\n // is written against this entity's type, the interface against any entity's.\n return {\n load,\n get,\n save,\n delete: del,\n } as unknown as EntityRepository;\n}\n"],"names":["randomUUID","deserializeEntityCollection","deserializeSingleEntity","extractMetaEntity","InvalidQuery","NotFound","serializeEntity","Effect","translateSqlError","translateFiltering","translateSorting","tableName","entityConstructor","metaEntity","key","name","toEntityId","entityOrId","id","makeSqlRepository","sql","table","load","request","gen","counted","clauses","try","where","filtering","orderBy","sorting","catch","error","reason","message","undefined","literal","page","pageSize","offset","rows","pipe","mapError","items","total","count","get","entity","fail","save","serialized","row","updatable","Object","keys","filter","column","onConflict","length","csv","map","insert","del","delete"],"mappings":"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"}
@@ -31,7 +31,7 @@ export declare const FOLD_TRANSLATION: {
31
31
  *
32
32
  * Every top-level entry is combined with `AND`, matching the Mongo translator.
33
33
  *
34
- * Throws {@link EntifixBuildError} for a member the entity did not declare
34
+ * Throws for a member the entity did not declare
35
35
  * `filterable` — synchronously, because this is a framework-free codec like the
36
36
  * rest of the entifix query stack; the repository wraps it back into an Effect.
37
37
  */
@@ -1 +1 @@
1
- {"version":3,"file":"sql-filter-translator.d.ts","sourceRoot":"","sources":["../../src/adapters/sql-filter-translator.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAItB,KAAK,eAAe,EACpB,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAY5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAWrE,CAAC;AAiHP;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,MAAM,EACvD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,SAAS,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,GACrC,SAAS,CAAC,QAAQ,GAAG,SAAS,CAsBhC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,SAAS,MAAM,EACrD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,GACjC,SAAS,CAAC,QAAQ,GAAG,SAAS,CA6ChC"}
1
+ {"version":3,"file":"sql-filter-translator.d.ts","sourceRoot":"","sources":["../../src/adapters/sql-filter-translator.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAItB,KAAK,eAAe,EACpB,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAY5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAWrE,CAAC;AAiHP;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,MAAM,EACvD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,SAAS,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,GACrC,SAAS,CAAC,QAAQ,GAAG,SAAS,CAsBhC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,SAAS,MAAM,EACrD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,GACjC,SAAS,CAAC,QAAQ,GAAG,SAAS,CA6ChC"}
@@ -52,8 +52,8 @@ function isFilterGroup(node) {
52
52
  * identifier, so it can never be taken on trust: it is looked up in the
53
53
  * entity's own allowlist (`filterable`/`sortable` accessor metadata, the same
54
54
  * map `coerceRsql` validates against) and only the descriptor's stored `key` is
55
- * ever emitted. A member the entity did not declare is an
56
- * {@link EntifixBuildError}, which the service maps to a `400`.
55
+ * ever emitted. A member the entity did not declare is refused by that
56
+ * allowlist, which the repository answers as an `InvalidQuery` — a `400`.
57
57
  *
58
58
  * A `localized` member is stored as one `jsonb` column and queried a locale at
59
59
  * a time (`title.en`), which becomes `"title"->>$1`: the locale is a bound
@@ -125,7 +125,7 @@ function isFilterGroup(node) {
125
125
  *
126
126
  * Every top-level entry is combined with `AND`, matching the Mongo translator.
127
127
  *
128
- * Throws {@link EntifixBuildError} for a member the entity did not declare
128
+ * Throws for a member the entity did not declare
129
129
  * `filterable` — synchronously, because this is a framework-free codec like the
130
130
  * rest of the entifix query stack; the repository wraps it back into an Effect.
131
131
  */ export function translateFiltering(sql, entityConstructor, filtering) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/adapters/sql-filter-translator.ts"],"sourcesContent":["import {\n allowedDescriptors,\n type EntifixBuildError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityFilter,\n type EntityFilterBinaryOperator,\n type EntityFiltering,\n type EntitySorting,\n type FilterGroup,\n FOLD_TABLE,\n foldText,\n resolveAllowedProperty,\n} from '@entifix/core';\nimport type { SqlClient } from 'effect/sql';\nimport type { Statement } from 'effect/sql';\n\n/** SQL comparison for each binary operator. Fixed strings, never user input. */\nconst BINARY_OPERATORS: Record<EntityFilterBinaryOperator, string> = {\n eq: '=',\n ne: '<>',\n gt: '>',\n gte: '>=',\n lt: '<',\n lte: '<=',\n};\n\n/**\n * Escapes a value so it matches literally inside an `ILIKE` pattern: `%` and `_`\n * are the wildcards, and `\\` is Postgres' default escape character, so it has to\n * escape itself first — which a single character class does for free.\n *\n * This is the SQL counterpart to the Mongo translator's escaping: `like` is an\n * unanchored \"contains\" match in both, so a user typing `100%` searches for\n * that string rather than for everything.\n */\nexport function escapeLikePattern(value: string): string {\n return value.replace(/[\\\\%_]/g, '\\\\$&');\n}\n\n/**\n * The two strings `translate` maps a column through so `like` ignores accents:\n * every accented form, in both cases, onto its lowercase base letter — built\n * from core's `FOLD_TABLE`, so the column folds by the same rule the in-memory\n * matcher and the Mongo pattern use. `ILIKE` then handles the ASCII case.\n *\n * The built-in `translate` rather than `unaccent`, which is an extension a\n * database must have created before any query could use it.\n */\nexport const FOLD_TRANSLATION: { readonly from: string; readonly to: string } =\n (() => {\n let from = '';\n let to = '';\n for (const [base, forms] of Object.entries(FOLD_TABLE)) {\n // The table lists the base and its capital first; `ILIKE` folds those.\n const accented = forms.slice(2);\n from += accented;\n to += base.repeat(accented.length);\n }\n return { from, to };\n })();\n\nfunction isFilterGroup<TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> {\n // A group carries `values` + a logic operator and, unlike the array filter,\n // has no `property`.\n return (\n !('property' in node) && (node.operator === 'and' || node.operator === 'or')\n );\n}\n\n/**\n * Resolves a filter/sort member to the expression it compares, or rejects it.\n *\n * This is the security boundary of this file. A property name reaches SQL as an\n * identifier, so it can never be taken on trust: it is looked up in the\n * entity's own allowlist (`filterable`/`sortable` accessor metadata, the same\n * map `coerceRsql` validates against) and only the descriptor's stored `key` is\n * ever emitted. A member the entity did not declare is an\n * {@link EntifixBuildError}, which the service maps to a `400`.\n *\n * A `localized` member is stored as one `jsonb` column and queried a locale at\n * a time (`title.en`), which becomes `\"title\"->>$1`: the locale is a bound\n * parameter, never an identifier, and core has already checked it is one the\n * host declared.\n *\n * A request arriving through `parseLoadRequestParams` has already been through\n * this gate, so this is deliberately a second check — an `EntityLoadRequest`\n * built in code reaches the adapter without passing the URL codec at all.\n */\nfunction resolveExpression(\n sql: SqlClient.SqlClient,\n property: string | number | symbol,\n allowed: Map<string, EntityFieldDescriptor>,\n requirement: 'filterable' | 'sortable',\n): { key: string; expression: Statement.Fragment } {\n const { descriptor, key, locale } = resolveAllowedProperty(\n allowed,\n String(property),\n requirement,\n );\n return {\n key,\n expression:\n locale === undefined\n ? sql`${sql(descriptor.key)}`\n : sql`${sql(descriptor.key)}->>${locale}`,\n };\n}\n\n/**\n * Translates one filter node into a parameterized SQL fragment.\n *\n * Values are always interpolated as template parameters and column names always\n * through `sql(identifier)`, which the compiler quotes — this file never\n * concatenates a string into SQL.\n *\n * Returns `undefined` for a group with no members, which has no meaningful\n * translation (`AND ()` is a syntax error) and is dropped by the caller.\n */\nfunction translateNode<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n allowed: Map<string, EntityFieldDescriptor>,\n): Statement.Fragment | undefined {\n if (isFilterGroup(node)) {\n const children = node.values\n .map(child => translateNode(sql, child, allowed))\n .filter((child): child is Statement.Fragment => child !== undefined);\n if (children.length === 0) return undefined;\n return node.operator === 'or' ? sql.or(children) : sql.and(children);\n }\n\n const column = resolveExpression(\n sql,\n node.property,\n allowed,\n 'filterable',\n ).expression;\n\n switch (node.operator) {\n case 'eq':\n case 'ne':\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte':\n return sql`${column} ${sql.literal(BINARY_OPERATORS[node.operator])} ${node.value}`;\n case 'in':\n return sql`${column} IN ${sql.in(node.values)}`;\n case 'nin':\n return sql`${column} NOT IN ${sql.in(node.values)}`;\n case 'between':\n return sql`${column} BETWEEN ${node.start} AND ${node.end}`;\n case 'nbetween':\n return sql`${column} NOT BETWEEN ${node.start} AND ${node.end}`;\n case 'like':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'nlike':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) NOT ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'isNull':\n return sql`${column} IS NULL`;\n case 'isNotNull':\n return sql`${column} IS NOT NULL`;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const _never: never = node;\n return _never;\n }\n }\n}\n\n/**\n * Translates the request's `filtering` into a single `WHERE`-body fragment, or\n * `undefined` when there is nothing to filter on (the caller then omits the\n * clause entirely rather than emitting `WHERE TRUE`).\n *\n * Every top-level entry is combined with `AND`, matching the Mongo translator.\n *\n * Throws {@link EntifixBuildError} for a member the entity did not declare\n * `filterable` — synchronously, because this is a framework-free codec like the\n * rest of the entifix query stack; the repository wraps it back into an Effect.\n */\nexport function translateFiltering<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n filtering?: EntityFiltering<TEntity>[],\n): Statement.Fragment | undefined {\n if (!filtering || filtering.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.filterable,\n );\n\n const clauses: Statement.Fragment[] = [];\n for (const entry of filtering) {\n const nodes = Array.isArray(entry) ? entry : [entry];\n for (const node of nodes) {\n const clause = translateNode(sql, node, allowed);\n if (clause !== undefined) clauses.push(clause);\n }\n }\n\n if (clauses.length === 0) return undefined;\n if (clauses.length === 1) return clauses[0];\n return sql.and(clauses);\n}\n\n/**\n * Translates the request's `sorting` into an `ORDER BY`-body fragment, or\n * `undefined` when there is nothing to sort by.\n *\n * Priorities are applied in ascending numeric order within each record, and a\n * column named twice keeps its first position with the last direction — the same\n * outcome Mongo's sort document produces by assignment, so the two adapters\n * order identically.\n */\nexport function translateSorting<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n sorting?: EntitySorting<TEntity>[],\n): Statement.Fragment | undefined {\n if (!sorting || sorting.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.sortable,\n );\n // Keyed by what the property resolves to, so a member named twice — by its\n // name and its alias, say — keeps its first position with the last\n // direction, as Mongo's sort document does by assignment.\n const directions = new Map<\n string,\n { expression: Statement.Fragment; direction: 'ASC' | 'DESC' }\n >();\n\n for (const record of sorting) {\n for (const priority of Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)) {\n const entry = record[priority];\n if (entry) {\n const { key, expression } = resolveExpression(\n sql,\n entry.property,\n allowed,\n 'sortable',\n );\n directions.set(key, {\n expression: directions.get(key)?.expression ?? expression,\n direction: entry.type === 'desc' ? 'DESC' : 'ASC',\n });\n }\n }\n }\n\n if (directions.size === 0) return undefined;\n\n return sql.csv(\n [...directions.values()].map(\n ({ expression, direction }) =>\n sql`${expression} ${sql.literal(direction)}`,\n ),\n );\n}\n"],"names":["allowedDescriptors","FOLD_TABLE","foldText","resolveAllowedProperty","BINARY_OPERATORS","eq","ne","gt","gte","lt","lte","escapeLikePattern","value","replace","FOLD_TRANSLATION","from","to","base","forms","Object","entries","accented","slice","repeat","length","isFilterGroup","node","operator","resolveExpression","sql","property","allowed","requirement","descriptor","key","locale","String","expression","undefined","translateNode","children","values","map","child","filter","or","and","column","literal","in","start","end","_never","translateFiltering","entityConstructor","filtering","filterable","clauses","entry","nodes","Array","isArray","clause","push","translateSorting","sorting","sortable","directions","Map","record","priority","keys","Number","sort","left","right","set","get","direction","type","size","csv"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/adapters/sql-filter-translator.ts"],"sourcesContent":["import {\n allowedDescriptors,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityFilter,\n type EntityFilterBinaryOperator,\n type EntityFiltering,\n type EntitySorting,\n type FilterGroup,\n FOLD_TABLE,\n foldText,\n resolveAllowedProperty,\n} from '@entifix/core';\nimport type { SqlClient } from 'effect/sql';\nimport type { Statement } from 'effect/sql';\n\n/** SQL comparison for each binary operator. Fixed strings, never user input. */\nconst BINARY_OPERATORS: Record<EntityFilterBinaryOperator, string> = {\n eq: '=',\n ne: '<>',\n gt: '>',\n gte: '>=',\n lt: '<',\n lte: '<=',\n};\n\n/**\n * Escapes a value so it matches literally inside an `ILIKE` pattern: `%` and `_`\n * are the wildcards, and `\\` is Postgres' default escape character, so it has to\n * escape itself first — which a single character class does for free.\n *\n * This is the SQL counterpart to the Mongo translator's escaping: `like` is an\n * unanchored \"contains\" match in both, so a user typing `100%` searches for\n * that string rather than for everything.\n */\nexport function escapeLikePattern(value: string): string {\n return value.replace(/[\\\\%_]/g, '\\\\$&');\n}\n\n/**\n * The two strings `translate` maps a column through so `like` ignores accents:\n * every accented form, in both cases, onto its lowercase base letter — built\n * from core's `FOLD_TABLE`, so the column folds by the same rule the in-memory\n * matcher and the Mongo pattern use. `ILIKE` then handles the ASCII case.\n *\n * The built-in `translate` rather than `unaccent`, which is an extension a\n * database must have created before any query could use it.\n */\nexport const FOLD_TRANSLATION: { readonly from: string; readonly to: string } =\n (() => {\n let from = '';\n let to = '';\n for (const [base, forms] of Object.entries(FOLD_TABLE)) {\n // The table lists the base and its capital first; `ILIKE` folds those.\n const accented = forms.slice(2);\n from += accented;\n to += base.repeat(accented.length);\n }\n return { from, to };\n })();\n\nfunction isFilterGroup<TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> {\n // A group carries `values` + a logic operator and, unlike the array filter,\n // has no `property`.\n return (\n !('property' in node) && (node.operator === 'and' || node.operator === 'or')\n );\n}\n\n/**\n * Resolves a filter/sort member to the expression it compares, or rejects it.\n *\n * This is the security boundary of this file. A property name reaches SQL as an\n * identifier, so it can never be taken on trust: it is looked up in the\n * entity's own allowlist (`filterable`/`sortable` accessor metadata, the same\n * map `coerceRsql` validates against) and only the descriptor's stored `key` is\n * ever emitted. A member the entity did not declare is refused by that\n * allowlist, which the repository answers as an `InvalidQuery` — a `400`.\n *\n * A `localized` member is stored as one `jsonb` column and queried a locale at\n * a time (`title.en`), which becomes `\"title\"->>$1`: the locale is a bound\n * parameter, never an identifier, and core has already checked it is one the\n * host declared.\n *\n * A request arriving through `parseLoadRequestParams` has already been through\n * this gate, so this is deliberately a second check — an `EntityLoadRequest`\n * built in code reaches the adapter without passing the URL codec at all.\n */\nfunction resolveExpression(\n sql: SqlClient.SqlClient,\n property: string | number | symbol,\n allowed: Map<string, EntityFieldDescriptor>,\n requirement: 'filterable' | 'sortable',\n): { key: string; expression: Statement.Fragment } {\n const { descriptor, key, locale } = resolveAllowedProperty(\n allowed,\n String(property),\n requirement,\n );\n return {\n key,\n expression:\n locale === undefined\n ? sql`${sql(descriptor.key)}`\n : sql`${sql(descriptor.key)}->>${locale}`,\n };\n}\n\n/**\n * Translates one filter node into a parameterized SQL fragment.\n *\n * Values are always interpolated as template parameters and column names always\n * through `sql(identifier)`, which the compiler quotes — this file never\n * concatenates a string into SQL.\n *\n * Returns `undefined` for a group with no members, which has no meaningful\n * translation (`AND ()` is a syntax error) and is dropped by the caller.\n */\nfunction translateNode<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n allowed: Map<string, EntityFieldDescriptor>,\n): Statement.Fragment | undefined {\n if (isFilterGroup(node)) {\n const children = node.values\n .map(child => translateNode(sql, child, allowed))\n .filter((child): child is Statement.Fragment => child !== undefined);\n if (children.length === 0) return undefined;\n return node.operator === 'or' ? sql.or(children) : sql.and(children);\n }\n\n const column = resolveExpression(\n sql,\n node.property,\n allowed,\n 'filterable',\n ).expression;\n\n switch (node.operator) {\n case 'eq':\n case 'ne':\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte':\n return sql`${column} ${sql.literal(BINARY_OPERATORS[node.operator])} ${node.value}`;\n case 'in':\n return sql`${column} IN ${sql.in(node.values)}`;\n case 'nin':\n return sql`${column} NOT IN ${sql.in(node.values)}`;\n case 'between':\n return sql`${column} BETWEEN ${node.start} AND ${node.end}`;\n case 'nbetween':\n return sql`${column} NOT BETWEEN ${node.start} AND ${node.end}`;\n case 'like':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'nlike':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) NOT ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'isNull':\n return sql`${column} IS NULL`;\n case 'isNotNull':\n return sql`${column} IS NOT NULL`;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const _never: never = node;\n return _never;\n }\n }\n}\n\n/**\n * Translates the request's `filtering` into a single `WHERE`-body fragment, or\n * `undefined` when there is nothing to filter on (the caller then omits the\n * clause entirely rather than emitting `WHERE TRUE`).\n *\n * Every top-level entry is combined with `AND`, matching the Mongo translator.\n *\n * Throws for a member the entity did not declare\n * `filterable` — synchronously, because this is a framework-free codec like the\n * rest of the entifix query stack; the repository wraps it back into an Effect.\n */\nexport function translateFiltering<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n filtering?: EntityFiltering<TEntity>[],\n): Statement.Fragment | undefined {\n if (!filtering || filtering.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.filterable,\n );\n\n const clauses: Statement.Fragment[] = [];\n for (const entry of filtering) {\n const nodes = Array.isArray(entry) ? entry : [entry];\n for (const node of nodes) {\n const clause = translateNode(sql, node, allowed);\n if (clause !== undefined) clauses.push(clause);\n }\n }\n\n if (clauses.length === 0) return undefined;\n if (clauses.length === 1) return clauses[0];\n return sql.and(clauses);\n}\n\n/**\n * Translates the request's `sorting` into an `ORDER BY`-body fragment, or\n * `undefined` when there is nothing to sort by.\n *\n * Priorities are applied in ascending numeric order within each record, and a\n * column named twice keeps its first position with the last direction — the same\n * outcome Mongo's sort document produces by assignment, so the two adapters\n * order identically.\n */\nexport function translateSorting<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n sorting?: EntitySorting<TEntity>[],\n): Statement.Fragment | undefined {\n if (!sorting || sorting.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.sortable,\n );\n // Keyed by what the property resolves to, so a member named twice — by its\n // name and its alias, say — keeps its first position with the last\n // direction, as Mongo's sort document does by assignment.\n const directions = new Map<\n string,\n { expression: Statement.Fragment; direction: 'ASC' | 'DESC' }\n >();\n\n for (const record of sorting) {\n for (const priority of Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)) {\n const entry = record[priority];\n if (entry) {\n const { key, expression } = resolveExpression(\n sql,\n entry.property,\n allowed,\n 'sortable',\n );\n directions.set(key, {\n expression: directions.get(key)?.expression ?? expression,\n direction: entry.type === 'desc' ? 'DESC' : 'ASC',\n });\n }\n }\n }\n\n if (directions.size === 0) return undefined;\n\n return sql.csv(\n [...directions.values()].map(\n ({ expression, direction }) =>\n sql`${expression} ${sql.literal(direction)}`,\n ),\n );\n}\n"],"names":["allowedDescriptors","FOLD_TABLE","foldText","resolveAllowedProperty","BINARY_OPERATORS","eq","ne","gt","gte","lt","lte","escapeLikePattern","value","replace","FOLD_TRANSLATION","from","to","base","forms","Object","entries","accented","slice","repeat","length","isFilterGroup","node","operator","resolveExpression","sql","property","allowed","requirement","descriptor","key","locale","String","expression","undefined","translateNode","children","values","map","child","filter","or","and","column","literal","in","start","end","_never","translateFiltering","entityConstructor","filtering","filterable","clauses","entry","nodes","Array","isArray","clause","push","translateSorting","sorting","sortable","directions","Map","record","priority","keys","Number","sort","left","right","set","get","direction","type","size","csv"],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import { type StoreFailure } from '@entifix/core';
2
+ /**
3
+ * What a SQL error means, as a categorised failure
4
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)),
5
+ * read from the Postgres SQLSTATE wherever it sits in the cause chain —
6
+ * `@effect/sql` wraps the driver's error, and the code lives on the driver's.
7
+ *
8
+ * - `23505` unique violation → `DuplicateKey`
9
+ * - `40001` serialization failure, `40P01` deadlock → `Contention`
10
+ * - `57014` statement cancelled by timeout → `Timeout`
11
+ * - class `08` connection exceptions, and anything unrecognised → `Unavailable`
12
+ */
13
+ export declare const translateSqlError: (error: unknown, dependency?: string) => StoreFailure;
14
+ //# sourceMappingURL=translate-sql-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translate-sql-error.d.ts","sourceRoot":"","sources":["../../src/errors/translate-sql-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,OAAO,EACd,mBAAkB,KACjB,YAOF,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { Contention, DuplicateKey, isFailure, Timeout, Unavailable } from '@entifix/core';
2
+ /**
3
+ * What a SQL error means, as a categorised failure
4
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)),
5
+ * read from the Postgres SQLSTATE wherever it sits in the cause chain —
6
+ * `@effect/sql` wraps the driver's error, and the code lives on the driver's.
7
+ *
8
+ * - `23505` unique violation → `DuplicateKey`
9
+ * - `40001` serialization failure, `40P01` deadlock → `Contention`
10
+ * - `57014` statement cancelled by timeout → `Timeout`
11
+ * - class `08` connection exceptions, and anything unrecognised → `Unavailable`
12
+ */ export const translateSqlError = (error, dependency = 'sql')=>{
13
+ if (isFailure(error)) return error;
14
+ const code = sqlState(error);
15
+ if (code === '23505') return new DuplicateKey({});
16
+ if (code === '40001' || code === '40P01') return new Contention();
17
+ if (code === '57014') return new Timeout({
18
+ dependency
19
+ });
20
+ return new Unavailable({
21
+ dependency
22
+ });
23
+ };
24
+ const sqlState = (error)=>{
25
+ let current = error;
26
+ for(let depth = 0; current != null && depth < 5; depth += 1){
27
+ const code = current.code;
28
+ if (typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code)) return code;
29
+ current = current.cause;
30
+ }
31
+ return undefined;
32
+ };
33
+
34
+ //# sourceMappingURL=translate-sql-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/errors/translate-sql-error.ts"],"sourcesContent":["import {\n Contention,\n DuplicateKey,\n isFailure,\n type StoreFailure,\n Timeout,\n Unavailable,\n} from '@entifix/core';\n\n/**\n * What a SQL error means, as a categorised failure\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)),\n * read from the Postgres SQLSTATE wherever it sits in the cause chain —\n * `@effect/sql` wraps the driver's error, and the code lives on the driver's.\n *\n * - `23505` unique violation → `DuplicateKey`\n * - `40001` serialization failure, `40P01` deadlock → `Contention`\n * - `57014` statement cancelled by timeout → `Timeout`\n * - class `08` connection exceptions, and anything unrecognised → `Unavailable`\n */\nexport const translateSqlError = (\n error: unknown,\n dependency = 'sql',\n): StoreFailure => {\n if (isFailure(error)) return error as StoreFailure;\n const code = sqlState(error);\n if (code === '23505') return new DuplicateKey({});\n if (code === '40001' || code === '40P01') return new Contention();\n if (code === '57014') return new Timeout({ dependency });\n return new Unavailable({ dependency });\n};\n\nconst sqlState = (error: unknown): string | undefined => {\n let current: unknown = error;\n for (let depth = 0; current != null && depth < 5; depth += 1) {\n const code = (current as { code?: unknown }).code;\n if (typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code)) return code;\n current = (current as { cause?: unknown }).cause;\n }\n return undefined;\n};\n"],"names":["Contention","DuplicateKey","isFailure","Timeout","Unavailable","translateSqlError","error","dependency","code","sqlState","current","depth","test","cause","undefined"],"mappings":"AAAA,SACEA,UAAU,EACVC,YAAY,EACZC,SAAS,EAETC,OAAO,EACPC,WAAW,QACN,gBAAgB;AAEvB;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,oBAAoB,CAC/BC,OACAC,aAAa,KAAK;IAElB,IAAIL,UAAUI,QAAQ,OAAOA;IAC7B,MAAME,OAAOC,SAASH;IACtB,IAAIE,SAAS,SAAS,OAAO,IAAIP,aAAa,CAAC;IAC/C,IAAIO,SAAS,WAAWA,SAAS,SAAS,OAAO,IAAIR;IACrD,IAAIQ,SAAS,SAAS,OAAO,IAAIL,QAAQ;QAAEI;IAAW;IACtD,OAAO,IAAIH,YAAY;QAAEG;IAAW;AACtC,EAAE;AAEF,MAAME,WAAW,CAACH;IAChB,IAAII,UAAmBJ;IACvB,IAAK,IAAIK,QAAQ,GAAGD,WAAW,QAAQC,QAAQ,GAAGA,SAAS,EAAG;QAC5D,MAAMH,OAAO,AAACE,QAA+BF,IAAI;QACjD,IAAI,OAAOA,SAAS,YAAY,gBAAgBI,IAAI,CAACJ,OAAO,OAAOA;QACnEE,UAAU,AAACA,QAAgCG,KAAK;IAClD;IACA,OAAOC;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/sql",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "SQL adapter over @effect/sql: an entity repository with RSQL filter and sort translation, and a health probe.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,14 +51,14 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@swc/helpers": "0.5.23",
54
- "@entifix/business": "0.9.0",
55
- "@entifix/core": "0.9.0"
54
+ "@entifix/business": "0.10.1",
55
+ "@entifix/core": "0.10.1"
56
56
  },
57
57
  "devDependencies": {
58
- "effect": "4.0.5",
59
- "@entifix/testing-unit": "0.9.0"
58
+ "effect": "4.0.6",
59
+ "@entifix/testing-unit": "0.10.1"
60
60
  },
61
61
  "peerDependencies": {
62
- "effect": "^4.0.5"
62
+ "effect": "^4.0.6"
63
63
  }
64
64
  }