@classytic/repo-core 0.2.0 → 0.3.0

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.
@@ -132,5 +132,69 @@ interface KeysetPaginationResultCore<TDoc> {
132
132
  * for the rationale. Defaults to `{}`.
133
133
  */
134
134
  type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = KeysetPaginationResultCore<TDoc> & TExtra;
135
+ /**
136
+ * Core fields of an aggregate-paginated result. Don't consume this directly —
137
+ * use `AggregatePaginationResult<TDoc>` or `AggregatePaginationResult<TDoc, TExtra>`.
138
+ *
139
+ * Aggregate pagination produces page-shaped envelopes from arbitrary aggregate
140
+ * pipelines (mongokit's `aggregatePaginate` / `aggregatePipelinePaginate`,
141
+ * pgkit's CTE-based windowed counts, etc). The shape mirrors offset because
142
+ * the math is the same — the discriminant exists so consumers can route
143
+ * "this came from an aggregate, not a plain find" without inspecting the
144
+ * pipeline.
145
+ */
146
+ interface AggregatePaginationResultCore<TDoc> {
147
+ method: 'aggregate';
148
+ docs: TDoc[];
149
+ page: number;
150
+ limit: number;
151
+ total: number;
152
+ pages: number;
153
+ hasNext: boolean;
154
+ hasPrev: boolean;
155
+ }
156
+ /**
157
+ * Aggregate-paginated result envelope.
158
+ *
159
+ * `TExtra` parallels `OffsetPaginationResult` — kits surface deep-pagination
160
+ * warnings (`warning?: string`), aggregate-specific stats, etc.
161
+ */
162
+ type AggregatePaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = AggregatePaginationResultCore<TDoc> & TExtra;
163
+ /**
164
+ * Union of every pagination *result* shape (server-side, pre-wire).
165
+ *
166
+ * What kits return from `getAll` / `aggregatePaginate`. Use this as the
167
+ * input type to anything that converts repo results into HTTP envelopes —
168
+ * see {@link toCanonicalList}.
169
+ */
170
+ type AnyPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra>;
171
+ /** HTTP success envelope wrapping {@link OffsetPaginationResult}. */
172
+ type OffsetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
173
+ success: true;
174
+ } & OffsetPaginationResult<TDoc, TExtra>;
175
+ /** HTTP success envelope wrapping {@link KeysetPaginationResult}. */
176
+ type KeysetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
177
+ success: true;
178
+ } & KeysetPaginationResult<TDoc, TExtra>;
179
+ /** HTTP success envelope wrapping {@link AggregatePaginationResult}. */
180
+ type AggregatePaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
181
+ success: true;
182
+ } & AggregatePaginationResult<TDoc, TExtra>;
183
+ /**
184
+ * Bare list envelope — a successful response that wasn't paginated (raw
185
+ * array result). No `method` discriminant; consumers branch on the absence
186
+ * of pagination fields. Most useful when an endpoint sometimes paginates
187
+ * and sometimes returns a fixed-size list.
188
+ */
189
+ interface BareListResponse<TDoc> {
190
+ success: true;
191
+ docs: TDoc[];
192
+ }
193
+ /**
194
+ * Union of every wire envelope a paginated/list endpoint can emit. Locked
195
+ * to `success: true` because errors take a separate envelope shape — a
196
+ * client-side type guard checks `success` first, then `method`.
197
+ */
198
+ type PaginatedResponse<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResponse<TDoc, TExtra> | KeysetPaginationResponse<TDoc, TExtra> | AggregatePaginationResponse<TDoc, TExtra> | BareListResponse<TDoc>;
135
199
  //#endregion
136
- export { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginationConfig, SortDirection, SortSpec, ValueType };
200
+ export { AggregatePaginationResponse, AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResponse, CursorPayload, DecodedCursor, KeysetPaginationResponse, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResponse, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResponse, PaginationConfig, SortDirection, SortSpec, ValueType };
@@ -15,11 +15,13 @@ var RepositoryBase = class {
15
15
  this.modelName = options.name;
16
16
  this.hooks = new HookEngine(options.hooks ?? "async");
17
17
  const plugins = options.plugins ?? [];
18
+ for (let i = 0; i < plugins.length; i++) assertValidPlugin(plugins[i], this.modelName, i);
18
19
  validatePluginOrder(plugins, this.modelName, options.pluginOrderChecks ?? "warn", options.onPluginOrderWarning);
19
20
  for (const plugin of plugins) this.use(plugin);
20
21
  }
21
22
  /** Install a plugin (object with `apply(repo)` or a plain function). */
22
23
  use(plugin) {
24
+ assertValidPlugin(plugin, this.modelName);
23
25
  if (typeof plugin === "function") plugin(this);
24
26
  else plugin.apply(this);
25
27
  return this;
@@ -107,5 +109,24 @@ var RepositoryBase = class {
107
109
  return context["_cachedResult"];
108
110
  }
109
111
  };
112
+ /**
113
+ * Reject malformed plugin entries before they reach `use()`.
114
+ *
115
+ * Caught the field-reported `new Repository(Model, ['organizationId'], ...)`
116
+ * crash where a tenant-field string array landed in the plugins slot and
117
+ * blew up with `TypeError: plugin.apply is not a function` deep inside the
118
+ * constructor. Validating shape up front turns that into a single, action-
119
+ * able error pointing at the offending index.
120
+ */
121
+ function assertValidPlugin(plugin, repoName, index) {
122
+ const where = typeof index === "number" ? `plugin at index ${index}` : "plugin";
123
+ if (plugin === null || plugin === void 0) throw new TypeError(`[repo-core] Repository "${repoName}": ${where} is ${plugin === null ? "null" : "undefined"}. Expected a function \`(repo) => void\` or an object \`{ name, apply(repo) }\`.`);
124
+ if (typeof plugin === "function") return;
125
+ if (typeof plugin !== "object") {
126
+ const detail = typeof plugin === "string" ? `'${plugin}'` : "";
127
+ throw new TypeError(`[repo-core] Repository "${repoName}": ${where} has wrong type. Expected a function or { name, apply(repo) } object — got ${typeof plugin} ${detail}. Common cause: \`new Repository(Model, [tenantField], opts)\` — second argument must be a plugins array.`);
128
+ }
129
+ if (typeof plugin.apply !== "function") throw new TypeError(`[repo-core] Repository "${repoName}": ${where} is an object but missing \`apply(repo)\`. Expected \`{ name: string, apply: (repo) => void }\`.`);
130
+ }
110
131
  //#endregion
111
132
  export { RepositoryBase };
@@ -4,17 +4,28 @@ import { JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs"
4
4
  /**
5
5
  * Collect the set of fields that must NOT appear in a generated schema.
6
6
  *
7
- * Combines four sources in priority order:
8
- * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
9
- * 2. `fieldRules[field].systemManaged` hidden from both create & update.
10
- * 3. For update schemas: `fieldRules[field].immutable` /
11
- * `immutableAfterCreate` → hidden from update only.
12
- * 4. `options.create.omitFields` / `options.update.omitFields` — explicit
13
- * caller-provided omit list for the matching purpose.
7
+ * Three purposes have three different policies:
8
+ *
9
+ * - `'create'` / `'update'` (request-body schemas):
10
+ * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
11
+ * 2. `fieldRules[field].systemManaged` → hidden from both.
12
+ * 3. `'update'` only: `fieldRules[field].immutable` /
13
+ * `immutableAfterCreate` hidden from update.
14
+ * 4. `options.create.omitFields` / `options.update.omitFields` —
15
+ * explicit caller-provided omit list for the matching purpose.
16
+ *
17
+ * - `'response'` (response-shape schema):
18
+ * 1. `fieldRules[field].hidden: true` ONLY — passwords, secrets,
19
+ * internal scoring. Server-set fields (`createdAt`, `updatedAt`,
20
+ * `_id`, systemManaged, immutable / readonly) ARE returned to
21
+ * clients and so ARE included in the response shape.
22
+ * 2. `options.response?.omitFields` — explicit caller-provided omit
23
+ * list when the host wants to strip extra fields from responses
24
+ * without marking them `hidden` globally.
14
25
  *
15
26
  * Returns a fresh `Set<string>` so callers can freely mutate.
16
27
  */
17
- declare function collectFieldsToOmit(options: SchemaBuilderOptions, purpose: 'create' | 'update'): Set<string>;
28
+ declare function collectFieldsToOmit(options: SchemaBuilderOptions, purpose: 'create' | 'update' | 'response'): Set<string>;
18
29
  /**
19
30
  * Apply omissions + `optional` overrides to a built JSON Schema in place.
20
31
  *
@@ -2,23 +2,43 @@
2
2
  /**
3
3
  * Collect the set of fields that must NOT appear in a generated schema.
4
4
  *
5
- * Combines four sources in priority order:
6
- * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
7
- * 2. `fieldRules[field].systemManaged` hidden from both create & update.
8
- * 3. For update schemas: `fieldRules[field].immutable` /
9
- * `immutableAfterCreate` → hidden from update only.
10
- * 4. `options.create.omitFields` / `options.update.omitFields` — explicit
11
- * caller-provided omit list for the matching purpose.
5
+ * Three purposes have three different policies:
6
+ *
7
+ * - `'create'` / `'update'` (request-body schemas):
8
+ * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
9
+ * 2. `fieldRules[field].systemManaged` → hidden from both.
10
+ * 3. `'update'` only: `fieldRules[field].immutable` /
11
+ * `immutableAfterCreate` hidden from update.
12
+ * 4. `options.create.omitFields` / `options.update.omitFields` —
13
+ * explicit caller-provided omit list for the matching purpose.
14
+ *
15
+ * - `'response'` (response-shape schema):
16
+ * 1. `fieldRules[field].hidden: true` ONLY — passwords, secrets,
17
+ * internal scoring. Server-set fields (`createdAt`, `updatedAt`,
18
+ * `_id`, systemManaged, immutable / readonly) ARE returned to
19
+ * clients and so ARE included in the response shape.
20
+ * 2. `options.response?.omitFields` — explicit caller-provided omit
21
+ * list when the host wants to strip extra fields from responses
22
+ * without marking them `hidden` globally.
12
23
  *
13
24
  * Returns a fresh `Set<string>` so callers can freely mutate.
14
25
  */
15
26
  function collectFieldsToOmit(options, purpose) {
27
+ const rules = options?.fieldRules ?? {};
28
+ const globalExcludes = options?.excludeFields ?? [];
29
+ if (purpose === "response") {
30
+ const result = new Set(globalExcludes);
31
+ for (const [field, rule] of Object.entries(rules)) if (rule.hidden) result.add(field);
32
+ const explicit = options?.response?.omitFields;
33
+ if (explicit) for (const f of explicit) result.add(f);
34
+ return result;
35
+ }
16
36
  const result = new Set([
17
37
  "createdAt",
18
38
  "updatedAt",
19
- "__v"
39
+ "__v",
40
+ ...globalExcludes
20
41
  ]);
21
- const rules = options?.fieldRules ?? {};
22
42
  for (const [field, rule] of Object.entries(rules)) {
23
43
  if (rule.systemManaged) result.add(field);
24
44
  if (purpose === "update" && (rule.immutable || rule.immutableAfterCreate)) result.add(field);
@@ -0,0 +1,72 @@
1
+ import { CrudSchemas, SchemaBuilderOptions } from "./types.mjs";
2
+
3
+ //#region src/schema/generator.d.ts
4
+ /**
5
+ * Resource-level context threaded into the generator at boot. Lets the
6
+ * generator shape output to per-resource config (idField pattern,
7
+ * resource name for OpenAPI titles).
8
+ *
9
+ * All fields optional — generators that ignore the context still produce
10
+ * valid schemas; arc applies safety-net normalization downstream.
11
+ */
12
+ interface SchemaGeneratorContext {
13
+ /**
14
+ * The `idField` configured on the resource. Defaults to `'_id'` for
15
+ * Mongoose-shaped kits, `'id'` for SQL kits. Generators emit the
16
+ * matching `params.properties[idField]` so route-param validation
17
+ * matches the actual lookup field.
18
+ */
19
+ idField?: string;
20
+ /** Resource name (for OpenAPI titles, generator log messages). */
21
+ resourceName?: string;
22
+ }
23
+ /**
24
+ * Canonical generator contract. Functions that produce CRUD JSON schemas
25
+ * for a kit satisfy this shape — `mongokit/buildCrudSchemasFromModel`,
26
+ * `sqlitekit/buildCrudSchemasFromTable`, etc.
27
+ *
28
+ * The return type is intentionally widened to `CrudSchemas | Record<string,
29
+ * unknown>` so kits that emit additional vendor-specific schema fields
30
+ * (`x-ref`, `x-foreign-key`, OpenAPI extensions) flow through without
31
+ * type erosion. Arc's adapter post-processes via `mergeFieldRuleConstraints`
32
+ * so portable `fieldRules` constraints (`minLength`/`maxLength`/`min`/
33
+ * `max`/`pattern`/`enum`/`description`/`nullable`) apply uniformly across
34
+ * kit outputs.
35
+ *
36
+ * @typeParam TModel - The kit's native model / table type. `Model<unknown>`
37
+ * for Mongoose kits, a Drizzle `Table` for SQL kits, etc. Widened to
38
+ * `unknown` by default so adapters that don't care about model typing
39
+ * (or cross-kit utilities) pass any model through.
40
+ *
41
+ * @example mongokit conformance (one-line `satisfies`)
42
+ * ```ts
43
+ * import type { SchemaGenerator } from '@classytic/repo-core/schema';
44
+ *
45
+ * export const buildCrudSchemasFromModel = ((model, options, ctx) => {
46
+ * // ... existing impl
47
+ * }) satisfies SchemaGenerator<Model<unknown>>;
48
+ * ```
49
+ *
50
+ * @example arc adapter typing
51
+ * ```ts
52
+ * import type { SchemaGenerator } from '@classytic/repo-core/schema';
53
+ *
54
+ * interface MongooseAdapterOptions<TDoc> {
55
+ * schemaGenerator?: SchemaGenerator<Model<unknown>>;
56
+ * }
57
+ * ```
58
+ */
59
+ type SchemaGenerator<TModel = unknown> = (model: TModel, options?: SchemaBuilderOptions, context?: SchemaGeneratorContext) => CrudSchemas | Record<string, unknown>;
60
+ /**
61
+ * Runtime predicate — true when `value` matches the generator shape.
62
+ *
63
+ * Conservative: only checks `typeof value === 'function'` and arity.
64
+ * Doesn't invoke the function with a sentinel argument because doing so
65
+ * could trigger expensive schema introspection on a single test call.
66
+ * The structural-typing alignment (`satisfies SchemaGenerator<...>`) is
67
+ * the primary contract enforcement; this guard is for runtime hosts that
68
+ * accept either a generator or a config-bag.
69
+ */
70
+ declare function isSchemaGenerator(value: unknown): value is SchemaGenerator;
71
+ //#endregion
72
+ export { SchemaGenerator, SchemaGeneratorContext, isSchemaGenerator };
@@ -0,0 +1,16 @@
1
+ //#region src/schema/generator.ts
2
+ /**
3
+ * Runtime predicate — true when `value` matches the generator shape.
4
+ *
5
+ * Conservative: only checks `typeof value === 'function'` and arity.
6
+ * Doesn't invoke the function with a sentinel argument because doing so
7
+ * could trigger expensive schema introspection on a single test call.
8
+ * The structural-typing alignment (`satisfies SchemaGenerator<...>`) is
9
+ * the primary contract enforcement; this guard is for runtime hosts that
10
+ * accept either a generator or a config-bag.
11
+ */
12
+ function isSchemaGenerator(value) {
13
+ return typeof value === "function" && value.length >= 1 && value.length <= 3;
14
+ }
15
+ //#endregion
16
+ export { isSchemaGenerator };
@@ -1,3 +1,4 @@
1
1
  import { CrudSchemas, FieldRule, FieldRules, JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs";
2
2
  import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
3
- export { type CrudSchemas, type FieldRule, type FieldRules, type JsonSchema, type SchemaBuilderOptions, type ValidationResult, applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };
3
+ import { SchemaGenerator, SchemaGeneratorContext, isSchemaGenerator } from "./generator.mjs";
4
+ export { type CrudSchemas, type FieldRule, type FieldRules, type JsonSchema, type SchemaBuilderOptions, type SchemaGenerator, type SchemaGeneratorContext, type ValidationResult, applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, validateUpdateBody };
@@ -1,2 +1,3 @@
1
1
  import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
2
- export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };
2
+ import { isSchemaGenerator } from "./generator.mjs";
3
+ export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, validateUpdateBody };
@@ -26,6 +26,16 @@ interface FieldRule {
26
26
  systemManaged?: boolean;
27
27
  /** Remove from `required[]` in the generated schema. DB-level constraints unaffected. */
28
28
  optional?: boolean;
29
+ /**
30
+ * Strip the field from the response shape. Use for passwords, secrets,
31
+ * internal scoring — anything the server stores but should never echo.
32
+ *
33
+ * Distinct from `systemManaged` (which only affects request bodies):
34
+ * `hidden` is a *response* concern and lives at the schema-builder
35
+ * boundary so kits, OpenAPI tooling, and arc's response serializer
36
+ * narrow on the same flag.
37
+ */
38
+ hidden?: boolean;
29
39
  }
30
40
  /** Map of field name → FieldRule. */
31
41
  interface FieldRules {
@@ -56,9 +66,10 @@ interface JsonSchema {
56
66
  [key: `x-${string}`]: unknown;
57
67
  }
58
68
  /**
59
- * CRUD schema bundle — the four JSON Schemas every HTTP endpoint needs:
60
- * body validation on POST / PATCH, route-param validation on id routes, and
61
- * query-string validation on list endpoints.
69
+ * CRUD schema bundle — the JSON Schemas every HTTP endpoint needs:
70
+ * body validation on POST / PATCH, route-param validation on id routes,
71
+ * query-string validation on list endpoints, and (optionally) response-shape
72
+ * documentation for OpenAPI / strict reply serialization.
62
73
  */
63
74
  interface CrudSchemas {
64
75
  /** JSON Schema for create request body (POST). */
@@ -69,6 +80,23 @@ interface CrudSchemas {
69
80
  params: JsonSchema;
70
81
  /** JSON Schema for list/query parameters. */
71
82
  listQuery: JsonSchema;
83
+ /**
84
+ * JSON Schema for response shape (optional).
85
+ *
86
+ * Includes every field a client receives — server-set fields
87
+ * (`createdAt`, `updatedAt`, `_id`, immutable / readonly fields) ARE
88
+ * returned to clients and so ARE included in the response shape, in
89
+ * contrast to `createBody` / `updateBody` which exclude them. Only
90
+ * `fieldRules[field].hidden: true` excludes a field from responses
91
+ * (passwords, secrets, internal scoring).
92
+ *
93
+ * Set `additionalProperties: true` so virtuals and computed fields
94
+ * pass through without being stripped by AJV's strict serialization.
95
+ *
96
+ * Optional — kits that don't ship a response builder leave it unset
97
+ * and arc treats response validation as opt-out for that resource.
98
+ */
99
+ response?: JsonSchema;
72
100
  }
73
101
  /**
74
102
  * Options consumed by every kit's schema builder. Fields are additive:
@@ -78,6 +106,18 @@ interface CrudSchemas {
78
106
  interface SchemaBuilderOptions {
79
107
  /** Field rules for create/update schemas. */
80
108
  fieldRules?: FieldRules;
109
+ /**
110
+ * Global field exclusion — fields listed here are dropped from EVERY
111
+ * generated schema (create / update / response). Shortcut for setting
112
+ * `create.omitFields`, `update.omitFields`, AND `response.omitFields`
113
+ * to the same list. Use for fields that should never appear in any
114
+ * HTTP-facing schema (e.g. internal-only columns, framework-private
115
+ * fields).
116
+ *
117
+ * Per-purpose overrides still apply on top — a field listed here AND
118
+ * in `create.omitFields` is dropped once.
119
+ */
120
+ excludeFields?: string[];
81
121
  /**
82
122
  * When `true`, emit `"additionalProperties": false` on create/update/query
83
123
  * schemas. Default `false` so generators stay permissive by default;
@@ -113,6 +153,19 @@ interface SchemaBuilderOptions {
113
153
  type: string;
114
154
  } | unknown>;
115
155
  };
156
+ /**
157
+ * Response-schema overrides.
158
+ *
159
+ * Response shape includes server-set fields (`createdAt`, `updatedAt`,
160
+ * `_id`, immutable / readonly / systemManaged fields) since those ARE
161
+ * returned to clients. Only `fieldRules[field].hidden: true` fields are
162
+ * stripped automatically. Use `omitFields` to drop additional fields
163
+ * from responses without marking them globally hidden (e.g. internal
164
+ * scoring you want kept in update bodies but stripped from list reads).
165
+ */
166
+ response?: {
167
+ /** Extra fields to omit from the response shape. */omitFields?: string[];
168
+ };
116
169
  /**
117
170
  * Emit OpenAPI vendor extensions (`x-*` keywords like `x-ref` for populated
118
171
  * foreign-key fields).
@@ -0,0 +1,3 @@
1
+ import { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy } from "./types.mjs";
2
+ import { DEFAULT_TENANT_CONFIG, resolveTenantConfig } from "./resolve.mjs";
3
+ export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig };
@@ -0,0 +1,2 @@
1
+ import { DEFAULT_TENANT_CONFIG, resolveTenantConfig } from "./resolve.mjs";
2
+ export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
@@ -0,0 +1,27 @@
1
+ import { ResolvedTenantConfig, TenantConfig } from "./types.mjs";
2
+
3
+ //#region src/tenant/resolve.d.ts
4
+ /**
5
+ * Sensible defaults for a freshly-built package (field strategy).
6
+ *
7
+ * `fieldType: 'objectId'` is the recommended default for new Mongo-shaped
8
+ * kits because it enables `$lookup` / `.populate()`. Existing kits that
9
+ * historically defaulted to `'string'` (mongokit pre-3.x) keep their own
10
+ * runtime default — `Pick<TenantConfig, 'fieldType'>` extension preserves
11
+ * type-level alignment without forcing a runtime default change.
12
+ */
13
+ declare const DEFAULT_TENANT_CONFIG: Required<Pick<TenantConfig, 'strategy' | 'enabled' | 'tenantField' | 'fieldType' | 'ref' | 'contextKey' | 'required'>>;
14
+ /**
15
+ * Resolve a possibly-partial {@link TenantConfig} against the defaults.
16
+ *
17
+ * - `false` → `enabled: false`, `strategy: 'none'`, `required: false`.
18
+ * - `true` / `undefined` → default field strategy.
19
+ * - Object with `strategy: 'custom'` → `resolve` is required; throws
20
+ * otherwise so the misconfiguration surfaces at boot, not runtime.
21
+ * - Object with `strategy: 'none'` → `enabled: false` (preserves
22
+ * user-supplied `tenantField` / `fieldType` / `ref` so the doc field
23
+ * stays correctly typed even with scoping off).
24
+ */
25
+ declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedTenantConfig;
26
+ //#endregion
27
+ export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
@@ -0,0 +1,69 @@
1
+ //#region src/tenant/resolve.ts
2
+ /**
3
+ * Sensible defaults for a freshly-built package (field strategy).
4
+ *
5
+ * `fieldType: 'objectId'` is the recommended default for new Mongo-shaped
6
+ * kits because it enables `$lookup` / `.populate()`. Existing kits that
7
+ * historically defaulted to `'string'` (mongokit pre-3.x) keep their own
8
+ * runtime default — `Pick<TenantConfig, 'fieldType'>` extension preserves
9
+ * type-level alignment without forcing a runtime default change.
10
+ */
11
+ const DEFAULT_TENANT_CONFIG = {
12
+ strategy: "field",
13
+ enabled: true,
14
+ tenantField: "organizationId",
15
+ fieldType: "objectId",
16
+ ref: "organization",
17
+ contextKey: "organizationId",
18
+ required: true
19
+ };
20
+ /**
21
+ * Resolve a possibly-partial {@link TenantConfig} against the defaults.
22
+ *
23
+ * - `false` → `enabled: false`, `strategy: 'none'`, `required: false`.
24
+ * - `true` / `undefined` → default field strategy.
25
+ * - Object with `strategy: 'custom'` → `resolve` is required; throws
26
+ * otherwise so the misconfiguration surfaces at boot, not runtime.
27
+ * - Object with `strategy: 'none'` → `enabled: false` (preserves
28
+ * user-supplied `tenantField` / `fieldType` / `ref` so the doc field
29
+ * stays correctly typed even with scoping off).
30
+ */
31
+ function resolveTenantConfig(config) {
32
+ if (config === false) return {
33
+ ...DEFAULT_TENANT_CONFIG,
34
+ strategy: "none",
35
+ enabled: false,
36
+ required: false
37
+ };
38
+ if (config === true || config === void 0) return { ...DEFAULT_TENANT_CONFIG };
39
+ const strategy = config.strategy ?? (config.enabled === false ? "none" : "field");
40
+ const contextKey = config.contextKey ?? config.tenantField ?? DEFAULT_TENANT_CONFIG.contextKey;
41
+ if (strategy === "none") return {
42
+ ...DEFAULT_TENANT_CONFIG,
43
+ ...config,
44
+ contextKey,
45
+ strategy: "none",
46
+ enabled: false,
47
+ required: false
48
+ };
49
+ if (strategy === "custom") {
50
+ if (typeof config.resolve !== "function") throw new Error("[repo-core] TenantConfig.strategy 'custom' requires a 'resolve' function");
51
+ return {
52
+ ...DEFAULT_TENANT_CONFIG,
53
+ ...config,
54
+ contextKey,
55
+ strategy: "custom",
56
+ enabled: config.enabled ?? true,
57
+ resolve: config.resolve
58
+ };
59
+ }
60
+ return {
61
+ ...DEFAULT_TENANT_CONFIG,
62
+ ...config,
63
+ contextKey,
64
+ strategy: "field",
65
+ enabled: config.enabled ?? true
66
+ };
67
+ }
68
+ //#endregion
69
+ export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
@@ -0,0 +1,142 @@
1
+ //#region src/tenant/types.d.ts
2
+ /**
3
+ * Tenant scope configuration — canonical static contract for the org.
4
+ *
5
+ * **`@classytic/repo-core/tenant` is the single source of truth.** Every
6
+ * multi-tenant-capable package (`@classytic/mongokit`, `@classytic/sqlitekit`,
7
+ * future kits, arc presets, services) consumes {@link TenantConfig} for its
8
+ * static fields and extends with kit-specific runtime callbacks via
9
+ * `Pick<TenantConfig, ...>` to lock the field vocabulary by structural typing.
10
+ *
11
+ * Three strategies are supported:
12
+ * - `'field'` (default) — filter every query by a scalar field on documents.
13
+ * The common case; used by `multiTenantPlugin` in mongokit and sqlitekit.
14
+ * - `'none'` — disable scoping entirely (single-tenant app). Equivalent to
15
+ * `enabled: false`; `strategy: 'none'` is the explicit form.
16
+ * - `'custom'` — caller supplies a `resolve(ctx)` function that returns the
17
+ * filter shape to inject. **The escape hatch for custom systems** —
18
+ * covers multi-field composite tenants, context-derived filters
19
+ * (region + partner id), non-scalar scope keys, or any tenancy model that
20
+ * doesn't fit the simple `field === id` pattern.
21
+ *
22
+ * **Why this layer is static-only.** Runtime callbacks (`skipWhen(ctx, op)`,
23
+ * `resolveContext()`, `resolveTenantId(ctx)`) genuinely differ across kits
24
+ * because their `RepositoryContext` shapes differ — mongokit's resolver
25
+ * returns just an id, sqlitekit's takes a richer context object. Each kit
26
+ * extends `TenantConfig` with its own runtime-callback fields. Hosts who
27
+ * need a single config object can compose: pass the static `TenantConfig`
28
+ * through {@link resolveTenantConfig} once, then forward the resolved
29
+ * static fields into each kit's runtime options alongside the kit-specific
30
+ * callbacks.
31
+ */
32
+ /**
33
+ * Storage / cast strategy for the tenant identifier on documents.
34
+ *
35
+ * - `'objectId'` (recommended for new packages) — `Schema.Types.ObjectId`
36
+ * with `ref`. Enables `$lookup`, `.populate()`, QueryParser `?lookup=...`
37
+ * on Mongo-shaped kits. SQL kits typically ignore this and rely on
38
+ * schema-defined column types instead.
39
+ * - `'string'` — plain string. Use when the host auth system issues UUIDs
40
+ * or slugs rather than ObjectIds.
41
+ */
42
+ type TenantFieldType = 'objectId' | 'string';
43
+ /** Scope resolution strategy. */
44
+ type TenantStrategy = 'field' | 'none' | 'custom';
45
+ interface TenantConfig {
46
+ /**
47
+ * Scope strategy. Omit for the common `'field'` case — explicit `'none'`
48
+ * / `'custom'` lets packages collapse what used to live in a separate
49
+ * `ScopeConfig` type.
50
+ *
51
+ * @default 'field'
52
+ */
53
+ strategy?: TenantStrategy;
54
+ /**
55
+ * Whether tenant scoping is active. When `false`, the package runs in
56
+ * single-tenant mode — no filter injection, no tenant field on documents.
57
+ * Equivalent to `strategy: 'none'`.
58
+ *
59
+ * @default true
60
+ */
61
+ enabled?: boolean;
62
+ /**
63
+ * Document / column field name that stores the tenant id. Used when
64
+ * `strategy === 'field'`.
65
+ *
66
+ * @default 'organizationId'
67
+ */
68
+ tenantField?: string;
69
+ /**
70
+ * How to store / cast the tenant id.
71
+ *
72
+ * @default 'objectId'
73
+ */
74
+ fieldType?: TenantFieldType;
75
+ /**
76
+ * Mongoose ref for `'objectId'` types. Ignored by SQL kits and when
77
+ * `fieldType === 'string'`.
78
+ *
79
+ * @default 'organization'
80
+ */
81
+ ref?: string;
82
+ /**
83
+ * Which key on the repository context to read the tenant id from.
84
+ *
85
+ * Defaults cascade: if omitted, falls back to the caller's `tenantField`
86
+ * (if supplied), else to `'organizationId'`. Rationale: when a host renames
87
+ * `tenantField` to e.g. `'branchId'`, their context almost always carries
88
+ * the value under the same key — mirroring `tenantField` is the
89
+ * least-surprise behavior. Override explicitly if the context key diverges
90
+ * from the document field (e.g. `tenantField: 'branchId'`,
91
+ * `contextKey: 'organizationId'`).
92
+ *
93
+ * @default tenantField ?? 'organizationId'
94
+ */
95
+ contextKey?: string;
96
+ /**
97
+ * Whether the field is required. When `false`, the package permits
98
+ * unscoped / cross-tenant reads (typically only for admin paths).
99
+ *
100
+ * @default true
101
+ */
102
+ required?: boolean;
103
+ /**
104
+ * Custom resolver — called when `strategy === 'custom'` to produce the
105
+ * filter object injected into queries. Packages pass the request /
106
+ * repository context; the resolver returns the filter shape.
107
+ *
108
+ * Use for tenancy models that don't fit the simple `field === id`
109
+ * pattern: multi-field composites, context-derived filters
110
+ * (region + partner id), hash-derived shards, etc.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * {
115
+ * strategy: 'custom',
116
+ * resolve: (ctx) => ({
117
+ * organizationId: ctx.organizationId,
118
+ * region: ctx.region,
119
+ * partnerId: ctx.partnerId,
120
+ * }),
121
+ * }
122
+ * ```
123
+ */
124
+ resolve?: (ctx: Record<string, unknown>) => Record<string, unknown>;
125
+ }
126
+ /**
127
+ * Resolved shape returned by `resolveTenantConfig`. Always includes the
128
+ * field defaults (so packages can inspect field names even when
129
+ * `enabled: false`) and threads `resolve` when `strategy === 'custom'`.
130
+ */
131
+ type ResolvedTenantConfig = {
132
+ strategy: TenantStrategy;
133
+ enabled: boolean;
134
+ tenantField: string;
135
+ fieldType: TenantFieldType;
136
+ ref: string;
137
+ contextKey: string;
138
+ required: boolean;
139
+ resolve?: TenantConfig['resolve'];
140
+ };
141
+ //#endregion
142
+ export { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy };