@c9up/atlas 0.1.3

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/db.darwin-arm64.node +0 -0
  4. package/db.darwin-x64.node +0 -0
  5. package/db.linux-arm64-gnu.node +0 -0
  6. package/db.linux-x64-gnu.node +0 -0
  7. package/db.win32-x64-msvc.node +0 -0
  8. package/index.darwin-arm64.node +0 -0
  9. package/index.darwin-x64.node +0 -0
  10. package/index.linux-arm64-gnu.node +0 -0
  11. package/index.linux-x64-gnu.node +0 -0
  12. package/index.win32-x64-msvc.node +0 -0
  13. package/package.json +69 -0
  14. package/scripts/copy-napi.mjs +86 -0
  15. package/src/AtlasProvider.ts +297 -0
  16. package/src/BaseEntity.ts +585 -0
  17. package/src/BaseRepository.ts +1694 -0
  18. package/src/ModelQuery.ts +2293 -0
  19. package/src/Transaction.ts +83 -0
  20. package/src/adapters/NapiDbAdapter.ts +178 -0
  21. package/src/config.ts +7 -0
  22. package/src/configure.ts +37 -0
  23. package/src/decorators/entity.ts +532 -0
  24. package/src/decorators/hooks.ts +169 -0
  25. package/src/decorators/scope.ts +44 -0
  26. package/src/errors.ts +111 -0
  27. package/src/index.ts +114 -0
  28. package/src/naming/NamingStrategy.ts +106 -0
  29. package/src/query/QueryBuilder.ts +422 -0
  30. package/src/query/native.ts +74 -0
  31. package/src/schema/Migration.ts +81 -0
  32. package/src/schema/MigrationRunner.ts +532 -0
  33. package/src/schema/Schema.ts +78 -0
  34. package/src/schema/SchemaBuilder.ts +14 -0
  35. package/src/schema/Seeder.ts +132 -0
  36. package/src/schema/TableBuilder.ts +238 -0
  37. package/src/schema/types.ts +51 -0
  38. package/src/services/db.ts +45 -0
  39. package/src/testing/DatabaseCleanup.ts +49 -0
  40. package/src/testing/Factory.ts +164 -0
  41. package/src/testing/TestDatabase.ts +81 -0
  42. package/src/testing/index.ts +3 -0
  43. package/src/utils/casing.ts +11 -0
  44. package/src/utils/dialectFromUrl.ts +16 -0
  45. package/src/utils/identifier.ts +35 -0
  46. package/src/utils/safePath.ts +59 -0
  47. package/src/utils/transactionBrand.ts +10 -0
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `scope()` — typed identity helper for declaring entity query scopes.
3
+ *
4
+ * Entity classes expose reusable filter/sort logic via `static scopes = {...}`.
5
+ * TypeScript can't infer the scope type on the naked object form, so this
6
+ * helper lets callers pin the argument types explicitly:
7
+ *
8
+ * class User extends BaseEntity {
9
+ * static scopes = {
10
+ * active: scope((q: ModelQuery<User>) => q.where('status', 'active')),
11
+ * forOrg: scope((q: ModelQuery<User>, org: Org) => q.where('org_id', org.id)),
12
+ * }
13
+ * }
14
+ *
15
+ * The runtime is an identity pass-through — `scope` returns its argument
16
+ * unchanged. Its only purpose is to give TS enough information to infer the
17
+ * scope's parameter types (and, by extension, the return type of
18
+ * `repo.query().apply(s => s.forOrg(...))` call sites).
19
+ *
20
+ * @implements Story 29.8 (scope helper)
21
+ */
22
+
23
+ import type { BaseEntity } from "../BaseEntity.js";
24
+ import type { ModelQuery } from "../ModelQuery.js";
25
+
26
+ /**
27
+ * A query scope function — takes the current query as first argument and
28
+ * an arbitrary list of extra arguments. Returns the query for chaining
29
+ * (or `void` — the chain is applied in place).
30
+ */
31
+ export type ScopeFn<TEntity extends BaseEntity, Args extends unknown[] = []> = (
32
+ query: ModelQuery<TEntity>,
33
+ ...args: Args
34
+ ) => ModelQuery<TEntity> | undefined;
35
+
36
+ /**
37
+ * Typed identity helper. Pass any scope function through `scope()` to get
38
+ * TS inference without changing the runtime behaviour.
39
+ */
40
+ export function scope<TEntity extends BaseEntity, Args extends unknown[] = []>(
41
+ fn: ScopeFn<TEntity, Args>,
42
+ ): ScopeFn<TEntity, Args> {
43
+ return fn;
44
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Atlas error hierarchy — structured errors for the ORM layer.
3
+ *
4
+ * Every error extends {@link AtlasError} with a stable code (prefixed `ATLAS_`),
5
+ * a human-readable message, and an optional hint. Specialised subclasses allow
6
+ * callers to catch by type (`catch (e) { if (e instanceof OptimisticLockError) ... }`)
7
+ * without string-matching on codes.
8
+ *
9
+ * @implements Story 32.10
10
+ */
11
+
12
+ export class AtlasError extends Error {
13
+ readonly code: string;
14
+ readonly hint?: string;
15
+
16
+ constructor(code: string, message: string, options?: { hint?: string }) {
17
+ super(message);
18
+ this.name = "AtlasError";
19
+ this.code = code.startsWith("ATLAS_") ? code : `ATLAS_${code}`;
20
+ this.hint = options?.hint;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Thrown when an entity cannot be found (find, findOrFail, firstOrFail).
26
+ */
27
+ export class EntityNotFoundError extends AtlasError {
28
+ readonly entityClass: string;
29
+ readonly criteria: unknown;
30
+
31
+ constructor(entityClass: string, criteria: unknown, hint?: string) {
32
+ super(
33
+ "E_ENTITY_NOT_FOUND",
34
+ `${entityClass} not found with ${JSON.stringify(criteria)}`,
35
+ { hint },
36
+ );
37
+ this.name = "EntityNotFoundError";
38
+ this.entityClass = entityClass;
39
+ this.criteria = criteria;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Thrown when an optimistic lock check fails on save — the row was modified
45
+ * by another transaction since we read it. Requires `@Version()` column on the entity.
46
+ */
47
+ export class OptimisticLockError extends AtlasError {
48
+ readonly entityClass: string;
49
+ readonly primaryKey: unknown;
50
+ readonly expectedVersion: number;
51
+
52
+ constructor(
53
+ entityClass: string,
54
+ primaryKey: unknown,
55
+ expectedVersion: number,
56
+ ) {
57
+ super(
58
+ "E_OPTIMISTIC_LOCK",
59
+ `Optimistic lock failure saving ${entityClass}#${String(primaryKey)}: the row was modified by another transaction (expected version ${expectedVersion}).`,
60
+ { hint: "Reload the entity, reapply your changes, and try again." },
61
+ );
62
+ this.name = "OptimisticLockError";
63
+ this.entityClass = entityClass;
64
+ this.primaryKey = primaryKey;
65
+ this.expectedVersion = expectedVersion;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Thrown when accessing a relation that was not eager-loaded AND has no lazy
71
+ * loader available on the entity. Forces callers to be explicit about loading.
72
+ */
73
+ export class RelationNotLoadedError extends AtlasError {
74
+ readonly entityClass: string;
75
+ readonly relationName: string;
76
+
77
+ constructor(entityClass: string, relationName: string) {
78
+ super(
79
+ "E_RELATION_NOT_LOADED",
80
+ `Relation '${relationName}' on ${entityClass} was not loaded.`,
81
+ {
82
+ hint: `Call .preload('${relationName}') on the query or .load('${relationName}') on the instance.`,
83
+ },
84
+ );
85
+ this.name = "RelationNotLoadedError";
86
+ this.entityClass = entityClass;
87
+ this.relationName = relationName;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Thrown when mass-assignment (`fill` / `merge` / `create`) tries to set a
93
+ * field that is not in the `fillable` list or is in the `guarded` list.
94
+ */
95
+ export class MassAssignmentError extends AtlasError {
96
+ readonly entityClass: string;
97
+ readonly attribute: string;
98
+
99
+ constructor(entityClass: string, attribute: string) {
100
+ super(
101
+ "E_MASS_ASSIGNMENT",
102
+ `Attribute '${attribute}' on ${entityClass} is not mass-assignable.`,
103
+ {
104
+ hint: `Add '${attribute}' to the static 'fillable' array or remove it from 'guarded'.`,
105
+ },
106
+ );
107
+ this.name = "MassAssignmentError";
108
+ this.entityClass = entityClass;
109
+ this.attribute = attribute;
110
+ }
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @module @c9up/atlas
3
+ * @description Atlas — Data Mapper ORM for the Ream framework
4
+ * @implements FR29, FR30, FR31, FR34, FR35, FR37
5
+ */
6
+
7
+ import "reflect-metadata";
8
+
9
+ export { SQLITE_PROD_PRAGMAS } from "./AtlasProvider.js";
10
+ export type { AsyncDatabaseConnection } from "./adapters/NapiDbAdapter.js";
11
+ export { createNapiConnection } from "./adapters/NapiDbAdapter.js";
12
+ export type { DomainEvent } from "./BaseEntity.js";
13
+ export { BaseEntity } from "./BaseEntity.js";
14
+ export type { DatabaseConnection } from "./BaseRepository.js";
15
+ export { BaseRepository } from "./BaseRepository.js";
16
+ export { defineConfig } from "./config.js";
17
+ export { configure } from "./configure.js";
18
+ export type {
19
+ ColumnAdapter,
20
+ ColumnMetadata,
21
+ ColumnOptions,
22
+ DateColumnConfig,
23
+ DateTimeColumnOptions,
24
+ EntityMetadata,
25
+ ManyToManyOptions,
26
+ RelationMetadata,
27
+ } from "./decorators/entity.js";
28
+ export {
29
+ BelongsTo,
30
+ Column,
31
+ column,
32
+ computed,
33
+ Entity,
34
+ getColumnMetadata,
35
+ getDateColumnConfig,
36
+ getEntityMetadata,
37
+ getPrimaryKey,
38
+ getRelationMetadata,
39
+ HasMany,
40
+ HasManyThrough,
41
+ HasOne,
42
+ HasOneThrough,
43
+ hasSoftDeletes,
44
+ ManyToMany,
45
+ PrimaryKey,
46
+ SoftDeletes,
47
+ } from "./decorators/entity.js";
48
+ export {
49
+ afterCreate,
50
+ afterDelete,
51
+ afterFetch,
52
+ afterFind,
53
+ afterPaginate,
54
+ afterSave,
55
+ afterUpdate,
56
+ beforeCreate,
57
+ beforeDelete,
58
+ beforeFetch,
59
+ beforeFind,
60
+ beforePaginate,
61
+ beforeSave,
62
+ beforeUpdate,
63
+ } from "./decorators/hooks.js";
64
+ export type { ScopeFn } from "./decorators/scope.js";
65
+ export { scope } from "./decorators/scope.js";
66
+ export {
67
+ AtlasError,
68
+ EntityNotFoundError,
69
+ MassAssignmentError,
70
+ OptimisticLockError,
71
+ RelationNotLoadedError,
72
+ } from "./errors.js";
73
+ export {
74
+ isAtlasStrictMode,
75
+ ModelQuery,
76
+ setAtlasStrictMode,
77
+ } from "./ModelQuery.js";
78
+ export type { NamingStrategy } from "./naming/NamingStrategy.js";
79
+ export {
80
+ CamelCaseNamingStrategy,
81
+ defaultNamingStrategy,
82
+ getNamingStrategy,
83
+ } from "./naming/NamingStrategy.js";
84
+ export type { AtlasDialect } from "./query/native.js";
85
+ export { getAtlasDialect, setAtlasDialect } from "./query/native.js";
86
+ export type {
87
+ CteDefinition,
88
+ ExistsClause,
89
+ OrderByClause,
90
+ QueryResult,
91
+ WhereClause,
92
+ WhereOperator,
93
+ } from "./query/QueryBuilder.js";
94
+ export { QueryBuilder, RawSql } from "./query/QueryBuilder.js";
95
+ export { Migration } from "./schema/Migration.js";
96
+ export type {
97
+ DatabaseAdapter,
98
+ MigrationRecord,
99
+ MigrationState,
100
+ MigrationStatus,
101
+ } from "./schema/MigrationRunner.js";
102
+ export { MigrationRunner } from "./schema/MigrationRunner.js";
103
+ export type { ColumnDefinition, ColumnType } from "./schema/SchemaBuilder.js";
104
+ export { Schema, TableBuilder } from "./schema/SchemaBuilder.js";
105
+ export {
106
+ BaseSeeder,
107
+ runSeederDirectory,
108
+ runSeeders,
109
+ Seeder,
110
+ } from "./schema/Seeder.js";
111
+ export type { TransactionClient } from "./Transaction.js";
112
+ export { transaction } from "./Transaction.js";
113
+ export { truncateAll, useTransaction } from "./testing/DatabaseCleanup.js";
114
+ export { factory } from "./testing/Factory.js";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Naming Strategy — override the snake_case ↔ camelCase convention per-entity.
3
+ *
4
+ * Each entity class can declare `static namingStrategy = new MyStrategy()` to
5
+ * replace the default camelCase ↔ snake_case conversion with its own rules.
6
+ * Used to migrate legacy databases (e.g. PascalCase columns, prefixed tables,
7
+ * custom pivot table names).
8
+ *
9
+ * class LegacyUser extends BaseEntity {
10
+ * static namingStrategy = new SnakeCaseSingularStrategy()
11
+ * }
12
+ *
13
+ * @implements Story 32.7
14
+ */
15
+
16
+ import { camelToSnake, snakeToCamel } from "../utils/casing.js";
17
+
18
+ /** Constructor-level hook for a naming override. Defaults to camelCase ↔ snake_case. */
19
+ export interface NamingStrategy {
20
+ /** Table name for an entity class, given its constructor name. */
21
+ tableName(className: string): string;
22
+ /** Database column name for a TS property (e.g. `userId` → `user_id`). */
23
+ columnName(propertyName: string): string;
24
+ /** Reverse mapping — DB column back to the TS property. Used by hydrate. */
25
+ propertyName(columnName: string): string;
26
+ /** Serialized field name in `toJSON()`. Defaults to the property name. */
27
+ serializedName(propertyName: string): string;
28
+ /** Local key for a belongsTo/hasMany relation (usually the parent PK). */
29
+ relationLocalKey(
30
+ kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
31
+ parentPk: string,
32
+ ): string;
33
+ /** Foreign key column name on the owning side of a relation. */
34
+ relationForeignKey(
35
+ kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
36
+ parentClass: string,
37
+ parentPk: string,
38
+ ): string;
39
+ /** Default pivot table name for a manyToMany relation. */
40
+ relationPivotTable(aClass: string, bClass: string): string;
41
+ }
42
+
43
+ /**
44
+ * Default strategy — camelCase TS properties, snake_case DB columns, plural
45
+ * snake_case table names, `<parent_name>_<parent_pk>` foreign keys.
46
+ */
47
+ export class CamelCaseNamingStrategy implements NamingStrategy {
48
+ tableName(className: string): string {
49
+ // Default: snake_case + plural-s. Entities wanting non-default (e.g. irregular
50
+ // plurals like "people") should override via static `namingStrategy`.
51
+ const snake = camelToSnake(className);
52
+ return snake.endsWith("s") ? snake : `${snake}s`;
53
+ }
54
+
55
+ columnName(propertyName: string): string {
56
+ return camelToSnake(propertyName);
57
+ }
58
+
59
+ propertyName(columnName: string): string {
60
+ return snakeToCamel(columnName);
61
+ }
62
+
63
+ serializedName(propertyName: string): string {
64
+ return propertyName;
65
+ }
66
+
67
+ relationLocalKey(
68
+ _kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
69
+ parentPk: string,
70
+ ): string {
71
+ return parentPk;
72
+ }
73
+
74
+ relationForeignKey(
75
+ _kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
76
+ parentClass: string,
77
+ parentPk: string,
78
+ ): string {
79
+ return `${camelToSnake(parentClass)}_${parentPk}`;
80
+ }
81
+
82
+ relationPivotTable(aClass: string, bClass: string): string {
83
+ // Sort alphabetically so `UserSkill` and `SkillUser` collapse to the same name.
84
+ const [x, y] = [camelToSnake(aClass), camelToSnake(bClass)].sort();
85
+ return `${x}_${y}`;
86
+ }
87
+ }
88
+
89
+ /** The default singleton — used when an entity doesn't override `static namingStrategy`. */
90
+ export const defaultNamingStrategy: NamingStrategy =
91
+ new CamelCaseNamingStrategy();
92
+
93
+ /**
94
+ * Resolve the naming strategy for an entity class. Walks the prototype chain
95
+ * so subclasses inherit their parent's strategy unless they override.
96
+ */
97
+ export function getNamingStrategy(entityClass: object): NamingStrategy {
98
+ let current: object | null = entityClass;
99
+ while (current && current !== Function.prototype) {
100
+ const explicit = (current as { namingStrategy?: NamingStrategy })
101
+ .namingStrategy;
102
+ if (explicit) return explicit;
103
+ current = Object.getPrototypeOf(current);
104
+ }
105
+ return defaultNamingStrategy;
106
+ }