@venizia/ignis-docs 0.0.8-0 → 0.0.8-2

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 (39) hide show
  1. package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
  2. package/dist/mcp-server/helpers/docs.helper.js +1 -1
  3. package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
  4. package/dist/mcp-server/tools/base.tool.d.ts +1 -1
  5. package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +1 -1
  6. package/dist/mcp-server/tools/docs/search-documents.tool.js +1 -1
  7. package/dist/mcp-server/tools/docs/search-documents.tool.js.map +1 -1
  8. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
  9. package/dist/mcp-server/tools/github/list-project-files.tool.js +1 -1
  10. package/dist/mcp-server/tools/github/list-project-files.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/github/search-code.tool.d.ts +1 -1
  12. package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
  13. package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
  14. package/package.json +14 -14
  15. package/wiki/extensions/components/authorization/api.md +239 -376
  16. package/wiki/extensions/components/authorization/errors.md +52 -43
  17. package/wiki/extensions/components/authorization/index.md +127 -65
  18. package/wiki/extensions/components/authorization/usage.md +198 -98
  19. package/wiki/extensions/helpers/kafka/consumer.md +6 -5
  20. package/wiki/extensions/helpers/kafka/examples.md +1 -1
  21. package/wiki/extensions/helpers/kafka/index.md +16 -12
  22. package/wiki/extensions/helpers/kafka/producer.md +4 -3
  23. package/wiki/guides/core-concepts/persistent/datasources.md +10 -11
  24. package/wiki/guides/core-concepts/persistent/index.md +6 -6
  25. package/wiki/guides/core-concepts/persistent/models.md +7 -5
  26. package/wiki/guides/core-concepts/persistent/repositories.md +11 -3
  27. package/wiki/guides/core-concepts/persistent/transactions.md +2 -1
  28. package/wiki/guides/core-concepts/rest-controllers.md +2 -2
  29. package/wiki/guides/core-concepts/services.md +0 -1
  30. package/wiki/guides/get-started/5-minute-quickstart.md +11 -10
  31. package/wiki/guides/migrations/scoped-rbac-migration.md +300 -0
  32. package/wiki/guides/tutorials/building-a-crud-api.md +43 -37
  33. package/wiki/guides/tutorials/complete-installation.md +64 -44
  34. package/wiki/guides/tutorials/ecommerce-api.md +21 -12
  35. package/wiki/guides/tutorials/realtime-chat.md +4 -5
  36. package/wiki/references/base/filter-system/default-filter.md +6 -3
  37. package/wiki/references/base/filter-system/fields-order-pagination.md +26 -0
  38. package/wiki/references/base/models.md +6 -3
  39. package/wiki/references/base/repositories/advanced.md +111 -0
@@ -485,6 +485,7 @@ import { Authorization, Authentication } from '@venizia/ignis';
485
485
  const user = c.get(Authentication.CURRENT_USER); // IAuthUser
486
486
  const rules = c.get(Authorization.RULES); // unknown (type depends on enforcer)
487
487
  const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
488
+ const domain = c.get(Authorization.DOMAIN); // string ("<Type>_<id>" | "SYSTEM_WIDE"), set when domain scoping is in play
488
489
 
489
490
  // Set skip dynamically
490
491
  c.set(Authorization.SKIP_AUTHORIZATION, true);
@@ -493,66 +494,6 @@ c.set(Authorization.SKIP_AUTHORIZATION, true);
493
494
  c.set(Authorization.RULES, null);
494
495
  ```
495
496
 
496
- ## Using IAuthorizationComparable
497
-
498
- For custom action/resource comparison logic beyond plain string equality, implement `IAuthorizationComparable`.
499
-
500
- ### StringAuthorizationAction with Wildcard
501
-
502
- The built-in `StringAuthorizationAction` supports a wildcard (`*`) that matches any action:
503
-
504
- ```typescript
505
- import { StringAuthorizationAction } from '@venizia/ignis';
506
-
507
- const wildcard = StringAuthorizationAction.build({ value: '*' });
508
- wildcard.isEqual('read'); // true — wildcard matches all
509
- wildcard.isEqual('delete'); // true — wildcard matches all
510
- wildcard.isEqual('create'); // true — wildcard matches all
511
-
512
- const readOnly = StringAuthorizationAction.build({ value: 'read' });
513
- readOnly.isEqual('read'); // true
514
- readOnly.isEqual('update'); // false
515
- ```
516
-
517
- ### StringAuthorizationResource
518
-
519
- Standard string comparison for resources (no wildcard):
520
-
521
- ```typescript
522
- import { StringAuthorizationResource } from '@venizia/ignis';
523
-
524
- const article = StringAuthorizationResource.build({ value: 'Article' });
525
- article.isEqual('Article'); // true
526
- article.isEqual('User'); // false
527
- ```
528
-
529
- ### Custom Comparable Implementation
530
-
531
- Create your own comparable type for advanced matching:
532
-
533
- ```typescript
534
- import type { IAuthorizationComparable } from '@venizia/ignis';
535
-
536
- class HierarchicalResource implements IAuthorizationComparable<string> {
537
- readonly value: string;
538
-
539
- constructor(opts: { value: string }) {
540
- this.value = opts.value;
541
- }
542
-
543
- compare(other: string): number {
544
- // Match if the other resource starts with this resource's value
545
- // e.g., 'articles' matches 'articles.comments'
546
- if (other.startsWith(this.value)) return 0;
547
- return this.value.localeCompare(other);
548
- }
549
-
550
- isEqual(other: string): boolean {
551
- return this.compare(other) === 0;
552
- }
553
- }
554
- ```
555
-
556
497
  ## Custom Enforcer
557
498
 
558
499
  Create a custom enforcer by implementing `IAuthorizationEnforcer`:
@@ -645,60 +586,50 @@ AuthorizationEnforcerRegistry.getInstance().register({
645
586
 
646
587
  ## Custom Filtered Adapter
647
588
 
648
- Create a custom adapter by extending `BaseFilteredAdapter`:
589
+ For most apps, use the ready-made [`ScopedCasbinAdapter`](./api#scopedcasbinadapter) — it reads a single
590
+ edge table and needs no subclassing. Write a custom adapter only when your storage model differs.
591
+
592
+ `BaseFilteredAdapter` is now thin: it provides the datasource/connector plumbing, the `isFiltered()`
593
+ flag, no-op write methods, and a `loadLines` helper. A subclass implements **only** `loadFilteredPolicy`
594
+ — query your store for ONE principal's policies and turn them into casbin lines.
649
595
 
650
596
  ```typescript
651
597
  import {
652
598
  BaseFilteredAdapter,
653
- IBaseFilteredAdapterEntities,
654
599
  ICasbinPolicyFilter,
655
- TBasePolicyRow,
600
+ type IDataSource,
656
601
  } from '@venizia/ignis';
602
+ import type { Model } from 'casbin';
657
603
 
658
- interface MyEntities extends IBaseFilteredAdapterEntities {
659
- permission: { tableName: string; principalType: string };
660
- role: { tableName: string; principalType: string };
661
- policyDefinition: { tableName: string; principalType: string };
662
- }
663
-
664
- class MyCustomAdapter extends BaseFilteredAdapter<MyEntities> {
665
- constructor(opts: { entities: MyEntities; /* your dependencies */ }) {
666
- super({ scope: MyCustomAdapter.name, entities: opts.entities });
604
+ // Narrow the filter if you like, or use the default ICasbinPolicyFilter ({ principal: { type, id } }).
605
+ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
606
+ constructor(opts: { dataSource: IDataSource }) {
607
+ super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
667
608
  }
668
609
 
669
- protected async buildDirectPolicies(opts: {
670
- filter: ICasbinPolicyFilter;
671
- rolePrincipal: string;
672
- }): Promise<string[]> {
673
- // Query direct permission policies for the user
674
- // Return casbin `p` lines using this.toPolicyLine()
675
- const rows = await this.queryDirectPolicies(opts.filter);
676
- return rows.map(row => this.toPolicyLine({ row })).filter(Boolean) as string[];
677
- }
610
+ async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
611
+ const { type, id } = filter.principal;
612
+
613
+ // 1. Query your store for THIS principal's policies (this.connector is provided by the base).
614
+ // 2. Build casbin lines as plain strings, e.g.:
615
+ // `p, User_${id}, Order, read, allow`
616
+ // `g, User_${id}, Role_42, *`
617
+ const lines: string[] = await this.buildLinesFor({ type, id });
678
618
 
679
- protected async buildGroupPolicies(opts: {
680
- filter: ICasbinPolicyFilter;
681
- }): Promise<{ lines: string[]; roleIds: (string | number)[] }> {
682
- // Query role assignments for the user
683
- // Return casbin `g` lines using this.toGroupLine() + role IDs
684
- return { lines: [...], roleIds: [...] };
619
+ // 3. Load them into the model with the base helper.
620
+ await this.loadLines({ model, lines });
685
621
  }
686
622
 
687
- protected async buildRolePolicies(opts: {
688
- roleIds: (string | number)[];
689
- rolePrincipal: string;
690
- }): Promise<string[]> {
691
- // Query permission policies inherited through roles
692
- // Return casbin `p` lines using this.toPolicyLine()
693
- return [...];
623
+ private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
624
+ // ...your queries via this.connector...
625
+ return [];
694
626
  }
695
627
  }
696
628
  ```
697
629
 
698
- The base class provides shared formatters:
699
- - `this.formatDomain(domain)` -- adds entity prefix to domain values
700
- - `this.toGroupLine({ subject, role, domain })` -- formats `g` lines
701
- - `this.toPolicyLine({ row })` -- formats `p` lines
630
+ The base no longer ships template-method hooks (`buildDirectPolicies`/`buildGroupPolicies`/…) or line
631
+ formatters — you own line construction. See `ScopedCasbinAdapter` for a full reference implementation
632
+ (role closure, structural trees, soft-delete, schema-qualified SQL).
702
633
 
703
634
  ## AuthorizationRole Comparison
704
635
 
@@ -789,6 +720,175 @@ const settings = registry.getAuthorizeModelSettings({ format: 'array' });
789
720
  > [!TIP]
790
721
  > Defining `authorize.principal` on the model makes the model the single source of truth for its authorization subject. This eliminates string duplication across route configs and policy setup.
791
722
 
723
+ ## RBAC with Domains (Multi-Tenant)
724
+
725
+ For multi-tenant apps where a user holds roles **scoped to specific tenants** (and sometimes globally), use Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) model and register a **domain matching function** via `domainMatching`. This lets the domain slot of a grouping (`g`) policy use a wildcard, so a global role is a single line (`g, user, role, *`) and role permissions stay domain-agnostic (`p, role, *, …`).
726
+
727
+ > [!TIP]
728
+ > Why this matters: putting the tenant on the membership (`g`) and keeping permissions wildcard (`p.dom = "*"`) keeps a user's materialized policy count **linear** (`memberships + permissions`) instead of the `permissions × tenants` cross-product. For a user with 30 tenants and 700 permissions that is ~730 lines instead of ~21,000.
729
+
730
+ There are two ways to do domain scoping:
731
+
732
+ - **Scoped model (recommended)** — set `isScoped: true` + use `ScopedCasbinAdapter` + the built-in
733
+ `CASBIN_RBAC_DOMAIN_SCOPED_MODEL`, and supply the request domain **per route** via `spec.domain` (or a
734
+ global `domainResolver`). The enforcer registers the matchers for you; you do not write `domainMatching`
735
+ or `normalizePayloadFn`.
736
+ - **Manual flat model (lower-level)** — keep a flat `g + p` model and register `domainMatching` +
737
+ `normalizePayloadFn` yourself. Documented below under [The model](#the-model).
738
+
739
+ ### Scoped model + per-route domain (recommended)
740
+
741
+ Register the scoped enforcer (see [Setup](./#step-3-register-enforcers-via-registry)) with `isScoped: true`,
742
+ then tell each route where to read its domain from. `IAuthorizationSpec.domain` accepts either a
743
+ **declarative source** or a **resolver function**:
744
+
745
+ ```typescript
746
+ import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';
747
+
748
+ // (a) Declarative — read the domain id from a request param/header/query/context var:
749
+ authorize({
750
+ spec: {
751
+ action: 'read',
752
+ resource: 'Order',
753
+ domain: { from: 'param', key: 'merchantId', type: 'Merchant' }, // → "Merchant_<param>"
754
+ },
755
+ });
756
+
757
+ // (b) Resolver — compute { type, id } yourself (return null → SYSTEM_WIDE):
758
+ authorize({
759
+ spec: {
760
+ action: 'read',
761
+ resource: 'Order',
762
+ domain: ({ context }) => {
763
+ const merchantId = resolveActiveMerchant({ context });
764
+ return merchantId ? { type: 'Merchant', id: merchantId } : null;
765
+ },
766
+ },
767
+ });
768
+ ```
769
+
770
+ Precedence (see `resolveRequestDomain`): `spec.domain` (resolver → declarative) → the global
771
+ `IAuthorizeOptions.domainResolver` → `SYSTEM_WIDE`. The resolved value is stashed on
772
+ `Authorization.DOMAIN` and passed to the enforcer as `request.domain`. A route with no domain at all
773
+ enforces `SYSTEM_WIDE` (super-admin scope) in scoped mode.
774
+
775
+ For a **global fallback** (apply the same resolver to every route that doesn't set `spec.domain`):
776
+
777
+ ```typescript
778
+ this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
779
+ defaultDecision: 'deny',
780
+ domainResolver: ({ context }) => {
781
+ const id = resolveActiveMerchant({ context });
782
+ return id ? { type: 'Merchant', id } : null;
783
+ },
784
+ });
785
+ ```
786
+
787
+ ### The model
788
+
789
+ ```ini
790
+ [request_definition]
791
+ r = sub, dom, obj, act
792
+
793
+ [policy_definition]
794
+ p = sub, dom, obj, act, eft
795
+
796
+ [role_definition]
797
+ g = _, _, _
798
+
799
+ [policy_effect]
800
+ e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
801
+
802
+ [matchers]
803
+ m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
804
+ ```
805
+
806
+ - `g = _, _, _` — the membership relation is **domain-aware** (subject, role, domain).
807
+ - `g(r.sub, p.sub, r.dom)` — the registered domain matching function decides whether the request domain matches the stored membership domain (this is what makes `*` a wildcard).
808
+ - `keyMatch(r.dom, p.dom)` — `keyMatch` is a **built-in matcher function** (no registration needed); it lets a permission with `p.dom = "*"` match any request domain.
809
+
810
+ ### Registering the domain matching function
811
+
812
+ Pass `domainMatching` in the enforcer options. It is registered once during `configure()`:
813
+
814
+ ```typescript
815
+ import {
816
+ AuthorizationEnforcerRegistry,
817
+ AuthorizationEnforcerTypes,
818
+ CasbinAuthorizationEnforcer,
819
+ CasbinDomainMatchingFunctions,
820
+ CasbinEnforcerModelDrivers,
821
+ type ICasbinEnforcerOptions,
822
+ } from '@venizia/ignis';
823
+
824
+ AuthorizationEnforcerRegistry.getInstance().register({
825
+ container: this,
826
+ enforcers: [
827
+ {
828
+ enforcer: CasbinAuthorizationEnforcer,
829
+ name: 'casbin',
830
+ type: AuthorizationEnforcerTypes.CASBIN,
831
+ options: {
832
+ model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_MODEL },
833
+ adapter,
834
+ cached,
835
+ // For a domain model, normalizePayloadFn MUST always return a `domain`.
836
+ normalizePayloadFn: ({ user, action, resource, context }) => ({
837
+ subject: `User_${user.userId}`,
838
+ domain: `Merchant_${resolveActiveMerchant({ context })}`,
839
+ resource,
840
+ action,
841
+ }),
842
+ // Register keyMatch on the `g` role definition so wildcard domains work:
843
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
844
+ } satisfies ICasbinEnforcerOptions,
845
+ },
846
+ ],
847
+ });
848
+ ```
849
+
850
+ > [!NOTE]
851
+ > `domainMatching` is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's `addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc)` internally — you never call it directly.
852
+
853
+ ### Choosing the matching function
854
+
855
+ `keyMatch` is the safe default for opaque domain identifiers like `Merchant_<uuid>`: it treats only `*` as special and never splits on `/` or `:`, so it cannot accidentally match one tenant against another. Use the others only if your domains are structured paths.
856
+
857
+ ```typescript
858
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH }; // * wildcard (recommended)
859
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_2 }; // /tenants/:id
860
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_3 }; // /tenants/{id}
861
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.REGEX_MATCH }; // ^Merchant_.*$
862
+ ```
863
+
864
+ ### All cases — policy lines and outcomes
865
+
866
+ Given the model above with `keyMatch` registered on `g`, the request is `enforceSync(subject, domain, resource, action)`:
867
+
868
+ | Case | Policy lines | Request | Outcome |
869
+ |------|--------------|---------|---------|
870
+ | **Scoped role** (owner/employee in a tenant) | `g, User_u, Role_owner, Merchant_A`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_A, Material.find, read)` | ✅ allow |
871
+ | **Scoped role — isolation** | (same as above) | `(User_u, Merchant_B, Material.find, read)` | ❌ deny (`g` domain doesn't match) |
872
+ | **Multi-tenant role** | `g, User_u, Role_owner, Merchant_A`<br/>`g, User_u, Role_owner, Merchant_B`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_B, Material.find, read)` | ✅ allow (one `g` line per owned tenant; **single** `p` line) |
873
+ | **Global role** (e.g. guest/onboarding) | `g, User_u, Role_guest, *`<br/>`p, Role_guest, *, Organizer.onBoarding, create, allow` | `(User_u, Merchant_anything, Organizer.onBoarding, create)` | ✅ allow (wildcard `g` domain) |
874
+ | **Direct user permission (scoped)** | `p, User_u, Merchant_A, Report.read, read, allow` | `(User_u, Merchant_A, Report.read, read)` | ✅ allow (reflexive `g(u,u,dom)` + `keyMatch`) |
875
+ | **Direct user permission — isolation** | (same as above) | `(User_u, Merchant_B, Report.read, read)` | ❌ deny |
876
+ | **Deny override** | `p, Role_x, *, Secret.read, read, deny`<br/>`p, Role_y, *, Secret.read, read, allow` | any domain where the user has both roles | ❌ deny (`!some(p.eft == deny)`) |
877
+
878
+ > [!IMPORTANT]
879
+ > The function is applied as `fn(requestDomain, policyDomain)` — the wildcard belongs on the **stored** side. Store only `*` or exact domain values (never `Merchant_*`) to keep isolation guaranteed.
880
+
881
+ ### Misconfiguration is caught early
882
+
883
+ If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws — Casbin would otherwise register the function as a silent no-op, leaving wildcard domains permanently unmatched (global roles silently denied):
884
+
885
+ ```typescript
886
+ // model declares `g` only
887
+ domainMatching: { roleDefinition: 'g2', fn: CasbinDomainMatchingFunctions.KEY_MATCH };
888
+ // => throws: Role definition "g2" is not declared in the Casbin model. Declare it under
889
+ // [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
890
+ ```
891
+
792
892
  ## See Also
793
893
 
794
894
  - [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
@@ -21,9 +21,10 @@ class KafkaConsumerHelper<
21
21
  | `start(opts)` | `(opts: IKafkaConsumeStartOptions): Promise<void>` | Start consuming (creates stream, wires callbacks) |
22
22
  | `startLagMonitoring(opts)` | `(opts: { topics: string[]; interval?: number }): void` | Start periodic lag monitoring |
23
23
  | `stopLagMonitoring()` | `(): void` | Stop lag monitoring |
24
- | `isHealthy()` | `(): boolean` | `true` when broker connected |
24
+ | `isHealthy()` | `(): boolean` | `true` when at least one broker connected |
25
25
  | `isReady()` | `(): boolean` | `isHealthy()` **and** `consumer.isActive()` |
26
26
  | `getHealthStatus()` | `(): TKafkaHealthStatus` | `'connected'` \| `'disconnected'` \| `'unknown'` |
27
+ | `getConnectedBrokerCount()` | `(): number` | Number of currently connected brokers |
27
28
  | `close(opts?)` | `(opts?: { isForce?: boolean }): Promise<void>` | Stop lag, close stream, close consumer |
28
29
 
29
30
  ## IKafkaConsumerOptions
@@ -41,8 +42,8 @@ interface IKafkaConsumerOptions<KeyType, ValueType, HeaderKeyType, HeaderValueTy
41
42
  | `identifier` | `string` | `'kafka-consumer'` | Scoped logging identifier |
42
43
  | `deserializers` | `Partial<Deserializers<K,V,HK,HV>>` | -- | Key/value/header deserializers |
43
44
  | `autocommit` | `boolean \| number` | `false` | Auto-commit offsets. `true` = default interval, `number` = custom ms |
44
- | `sessionTimeout` | `number` | `30000` | Session timeout -- consumer removed from group if no heartbeat |
45
- | `heartbeatInterval` | `number` | `3000` | Heartbeat interval -- must be less than `sessionTimeout` |
45
+ | `sessionTimeout` | `number` | `60000` | Session timeout -- consumer removed from group if no heartbeat |
46
+ | `heartbeatInterval` | `number` | `10000` | Heartbeat interval -- must be less than `sessionTimeout` |
46
47
  | `rebalanceTimeout` | `number` | `sessionTimeout` | Max time for rebalance. Defaults to the value of `sessionTimeout` |
47
48
  | `highWaterMark` | `number` | `1024` | Stream buffer size (messages) |
48
49
  | `minBytes` | `number` | `1` | Min bytes per fetch response |
@@ -141,8 +142,8 @@ await helper.start({ topics: ['orders'] });
141
142
  helper.startLagMonitoring({ topics: ['orders'], interval: 10_000 });
142
143
 
143
144
  // Health check
144
- helper.isHealthy(); // true when broker connected
145
- helper.isReady(); // true when broker connected AND consumer is active
145
+ helper.isHealthy(); // true when at least one broker connected
146
+ helper.isReady(); // true when at least one broker connected AND consumer is active
146
147
 
147
148
  // Shutdown
148
149
  await helper.close();
@@ -318,7 +318,7 @@ export class OrderEventService {
318
318
  | `Failed to deserialize a message` | Mismatch between serializer and deserializer | Ensure matching serde. For old data, use a new consumer group or recreate topic |
319
319
  | `JSON.stringify cannot serialize BigInt` | `message.offset` and `message.timestamp` are `bigint` | Use custom replacer: `(_k, v) => typeof v === 'bigint' ? v.toString() : v` |
320
320
  | Consumer idle (no messages) | More consumers than partitions | Ensure `numPartitions >= numConsumers` |
321
- | `isHealthy()` returns `false` | No broker connected yet, or connection lost | Check broker addresses, SASL config, network connectivity |
321
+ | `isHealthy()` returns `false` | All brokers disconnected (a single idle disconnect won't trigger this) | Check broker addresses, SASL config, network connectivity. Use `getConnectedBrokerCount()` for details |
322
322
  | `isReady()` returns `false` (consumer) | Consumer not active -- `start()` not called or stream closed | Call `await helper.start({ topics })` before checking readiness |
323
323
  | Graceful shutdown timeout | In-flight requests taking too long | Increase `shutdownTimeout` or use `close({ isForce: true })` |
324
324
 
@@ -16,9 +16,9 @@ The Kafka module provides four helper classes built on a shared `BaseKafkaHelper
16
16
  All helpers (except schema registry) extend `BaseKafkaHelper` which provides:
17
17
 
18
18
  - **Scoped logging** via `BaseHelper` (Winston with daily rotation)
19
- - **Health tracking** -- `isHealthy()`, `isReady()`, `getHealthStatus()`
19
+ - **Health tracking** -- per-broker connection tracking via `isHealthy()`, `isReady()`, `getHealthStatus()`, `getConnectedBrokerCount()`
20
20
  - **Broker event callbacks** -- `onBrokerConnect`, `onBrokerDisconnect`
21
- - **Broker failure tracking** -- automatic `configureBrokerFailed()` sets status to `'disconnected'`
21
+ - **Broker failure tracking** -- automatic `configureBrokerFailed()` sets status to `'disconnected'` only when all brokers are gone
22
22
  - **Graceful shutdown** -- timeout-based with force fallback
23
23
  - **Sensible defaults** via `KafkaDefaults` constants
24
24
  - **Factory pattern** via `newInstance()` static method
@@ -115,21 +115,25 @@ All Kafka helpers (except schema registry) extend `BaseKafkaHelper<TClient>`, wh
115
115
  ```typescript
116
116
  abstract class BaseKafkaHelper<TClient extends Base<BaseOptions>> extends BaseHelper {
117
117
  // Health
118
- isHealthy(): boolean; // healthStatus === 'connected'
119
- isReady(): boolean; // healthStatus === 'connected' (consumer overrides: + isActive())
120
- getHealthStatus(): TKafkaHealthStatus; // 'connected' | 'disconnected' | 'unknown'
118
+ isHealthy(): boolean; // true when at least one broker is connected
119
+ isReady(): boolean; // healthStatus === 'connected' (consumer overrides: + isActive())
120
+ getHealthStatus(): TKafkaHealthStatus; // 'connected' | 'disconnected' | 'unknown'
121
+ getConnectedBrokerCount(): number; // number of currently connected brokers
121
122
 
122
123
  // Shutdown (used by subclasses)
123
124
  protected closeClient(): Promise<void>;
124
125
  protected gracefulCloseClient(): Promise<void>; // races closeClient vs shutdownTimeout
126
+ protected resetHealthState(): void; // clears broker tracking + sets 'disconnected'
125
127
  }
126
128
  ```
127
129
 
130
+ Health tracking uses a **per-broker connection set** (`host:port` keys). A single idle broker disconnect does not make the client unhealthy -- only when **all** brokers are disconnected does `isHealthy()` return `false`.
131
+
128
132
  Health status transitions automatically via broker events:
129
- - `client:broker:connect` -> `'connected'`
130
- - `client:broker:disconnect` -> `'disconnected'`
131
- - `client:broker:failed` -> `'disconnected'`
132
- - `close()` -> `'disconnected'`
133
+ - `client:broker:connect` -> adds broker, sets `healthStatus` to `'connected'`
134
+ - `client:broker:disconnect` -> removes broker, sets `healthStatus` to `'disconnected'` only when all brokers are gone
135
+ - `client:broker:failed` -> removes broker, sets `healthStatus` to `'disconnected'` only when all brokers are gone
136
+ - `close()` -> clears all brokers, sets `healthStatus` to `'disconnected'`
133
137
 
134
138
  ## Connection Options
135
139
 
@@ -400,8 +404,8 @@ import { KafkaDefaults } from '@venizia/ignis-helpers/kafka';
400
404
  | `STRICT` | `true` | Producer | Fail on unknown topics |
401
405
  | `AUTOCREATE_TOPICS` | `false` | Producer | Auto-create topics on produce |
402
406
  | `AUTOCOMMIT` | `false` | Consumer | Auto-commit offsets |
403
- | `SESSION_TIMEOUT` | `30000` | Consumer | Session timeout in ms |
404
- | `HEARTBEAT_INTERVAL` | `3000` | Consumer | Heartbeat interval in ms |
407
+ | `SESSION_TIMEOUT` | `60000` | Consumer | Session timeout in ms |
408
+ | `HEARTBEAT_INTERVAL` | `10000` | Consumer | Heartbeat interval in ms |
405
409
  | `HIGH_WATER_MARK` | `1024` | Consumer | Stream buffer size (messages) |
406
410
  | `MIN_BYTES` | `1` | Consumer | Min bytes per fetch |
407
411
  | `METADATA_MAX_AGE` | `300000` | Consumer | Metadata cache TTL in ms |
@@ -570,7 +574,7 @@ const consumer = KafkaConsumerHelper.newInstance({
570
574
 
571
575
  ```typescript
572
576
  // All three -- identical API
573
- helper.isHealthy(); // true when broker connected
577
+ helper.isHealthy(); // true when at least one broker connected
574
578
  helper.isReady(); // Admin/Producer: same as isHealthy()
575
579
  // Consumer: isHealthy() + consumer.isActive()
576
580
  helper.getHealthStatus(); // 'connected' | 'disconnected' | 'unknown'
@@ -18,9 +18,10 @@ class KafkaProducerHelper<
18
18
  | `newInstance(opts)` | `static newInstance<K,V,HK,HV>(opts): KafkaProducerHelper<K,V,HK,HV>` | Factory method |
19
19
  | `getProducer()` | `(): Producer<K,V,HK,HV>` | Access the underlying `Producer` |
20
20
  | `runInTransaction(cb)` | `<R>(cb: TKafkaTransactionCallback<R,K,V,HK,HV>): Promise<R>` | Execute callback within a Kafka transaction |
21
- | `isHealthy()` | `(): boolean` | `true` when broker connected |
21
+ | `isHealthy()` | `(): boolean` | `true` when at least one broker connected |
22
22
  | `isReady()` | `(): boolean` | Same as `isHealthy()` |
23
23
  | `getHealthStatus()` | `(): TKafkaHealthStatus` | `'connected'` \| `'disconnected'` \| `'unknown'` |
24
+ | `getConnectedBrokerCount()` | `(): number` | Number of currently connected brokers |
24
25
  | `close(opts?)` | `(opts?: { isForce?: boolean }): Promise<void>` | Close the producer (default: graceful) |
25
26
 
26
27
  ## IKafkaProducerOptions
@@ -64,7 +65,7 @@ const helper = KafkaProducerHelper.newInstance({
64
65
  });
65
66
 
66
67
  // Health check
67
- helper.isHealthy(); // true when connected
68
+ helper.isHealthy(); // true when at least one broker connected
68
69
  helper.getHealthStatus(); // 'connected' | 'disconnected' | 'unknown'
69
70
 
70
71
  // Send messages via the underlying producer
@@ -153,7 +154,7 @@ If the callback throws, the transaction is automatically aborted and the error r
153
154
  2. **Force fallback**: If graceful times out, automatically force-closes
154
155
  3. **Force** (`{ isForce: true }`): Immediately calls `close(true)` without timeout protection
155
156
 
156
- After `close()`, `healthStatus` is set to `'disconnected'`.
157
+ After `close()`, all broker tracking is cleared and `healthStatus` is set to `'disconnected'`.
157
158
 
158
159
  ```typescript
159
160
  // Graceful (recommended)
@@ -12,7 +12,6 @@ A DataSource manages database connections and supports **schema auto-discovery**
12
12
  import {
13
13
  BaseDataSource,
14
14
  datasource,
15
- TNodePostgresConnector,
16
15
  ValueOrPromise,
17
16
  } from '@venizia/ignis';
18
17
  import { drizzle } from 'drizzle-orm/node-postgres';
@@ -32,11 +31,11 @@ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
32
31
  super({
33
32
  name: PostgresDataSource.name,
34
33
  config: {
35
- host: process.env.POSTGRES_HOST ?? 'localhost',
36
- port: +(process.env.POSTGRES_PORT ?? 5432),
37
- database: process.env.POSTGRES_DATABASE ?? 'mydb',
38
- user: process.env.POSTGRES_USER ?? 'postgres',
39
- password: process.env.POSTGRES_PASSWORD ?? '',
34
+ host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
35
+ port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
36
+ database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
37
+ user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
38
+ password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
40
39
  },
41
40
  // No schema needed - auto-discovered from @repository bindings!
42
41
  });
@@ -148,11 +147,11 @@ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
148
147
  super({
149
148
  name: PostgresDataSource.name,
150
149
  config: {
151
- host: process.env.POSTGRES_HOST ?? 'localhost',
152
- port: +(process.env.POSTGRES_PORT ?? 5432),
153
- database: process.env.POSTGRES_DATABASE ?? 'mydb',
154
- user: process.env.POSTGRES_USER ?? 'postgres',
155
- password: process.env.POSTGRES_PASSWORD ?? '',
150
+ host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
151
+ port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
152
+ database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
153
+ user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
154
+ password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
156
155
  },
157
156
  });
158
157
  }
@@ -46,16 +46,16 @@ export class User extends BaseEntity<typeof User.schema> {
46
46
 
47
47
  // 2. Create a DataSource
48
48
  @datasource({ driver: 'node-postgres' })
49
- export class PostgresDataSource extends BaseDataSource<TNodePostgresConnector, IDSConfigs> {
49
+ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
50
50
  constructor() {
51
51
  super({
52
52
  name: PostgresDataSource.name,
53
53
  config: {
54
- host: process.env.POSTGRES_HOST ?? 'localhost',
55
- port: +(process.env.POSTGRES_PORT ?? 5432),
56
- database: process.env.POSTGRES_DATABASE ?? 'mydb',
57
- user: process.env.POSTGRES_USER ?? 'postgres',
58
- password: process.env.POSTGRES_PASSWORD ?? '',
54
+ host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
55
+ port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
56
+ database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
57
+ user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
58
+ password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
59
59
  },
60
60
  });
61
61
  }
@@ -6,15 +6,17 @@ Models define your data structure using Drizzle ORM schemas. A model is a single
6
6
 
7
7
  ```typescript
8
8
  // src/models/entities/user.model.ts
9
- import { BaseEntity, extraUserColumns, generateIdColumnDefs, model } from '@venizia/ignis';
10
- import { pgTable } from 'drizzle-orm/pg-core';
9
+ import { BaseEntity, generateIdColumnDefs, generateTzColumnDefs, model } from '@venizia/ignis';
10
+ import { pgTable, text } from 'drizzle-orm/pg-core';
11
11
 
12
12
  @model({ type: 'entity' })
13
13
  export class User extends BaseEntity<typeof User.schema> {
14
14
  // Define schema as static property
15
15
  static override schema = pgTable('User', {
16
16
  ...generateIdColumnDefs({ id: { dataType: 'string' } }),
17
- ...extraUserColumns({ idType: 'string' }),
17
+ ...generateTzColumnDefs(),
18
+ name: text('name').notNull(),
19
+ email: text('email').notNull(),
18
20
  });
19
21
 
20
22
  // Relations (empty array if none)
@@ -126,7 +128,8 @@ static override schema = pgTable('User', {
126
128
  ```typescript
127
129
  static override schema = pgTable('User', {
128
130
  ...generateIdColumnDefs({ id: { dataType: 'string' } }), // id (text with UUID default)
129
- ...extraUserColumns({ idType: 'string' }), // status, audit fields, timestamps
131
+ ...generateTzColumnDefs(), // createdAt, modifiedAt
132
+ ...generateUserAuditColumnDefs(), // createdBy, modifiedBy
130
133
  // ... your fields
131
134
  });
132
135
  ```
@@ -139,7 +142,6 @@ static override schema = pgTable('User', {
139
142
  | `generateTzColumnDefs()` | `createdAt`, `modifiedAt` | Track timestamps |
140
143
  | `generateUserAuditColumnDefs()` | `createdBy`, `modifiedBy` | Track who created/updated |
141
144
  | `generateDataTypeColumnDefs()` | `dataType`, `tValue`, `nValue`, etc. | Configuration tables |
142
- | `extraUserColumns()` | Combines audit + status + type | Full-featured entities |
143
145
 
144
146
  :::note User Audit Options
145
147
  The `generateUserAuditColumnDefs` enricher supports an `allowAnonymous` option (default: `true`). Set to `false` to require authenticated user context and throw errors for anonymous operations:
@@ -159,10 +159,18 @@ All repository operations accept an `options` parameter with these fields:
159
159
  | `shouldSkipDefaultFilter` | `boolean` | Bypass the model's default filter (e.g., soft delete) |
160
160
 
161
161
  ```typescript
162
- // Create without returning data (faster for bulk inserts)
162
+ // Create without returning data (faster)
163
163
  await repo.create({
164
- data: bulkData,
165
- options: { shouldReturn: false }
164
+ data: { code: 'SETTING', group: 'SYSTEM' },
165
+ options: { shouldReturn: false },
166
+ });
167
+
168
+ // Bulk create multiple records
169
+ await repo.createAll({
170
+ data: [
171
+ { code: 'SETTING_A', group: 'SYSTEM' },
172
+ { code: 'SETTING_B', group: 'SYSTEM' },
173
+ ],
166
174
  });
167
175
 
168
176
  // Query with pagination range
@@ -62,7 +62,8 @@ Ignis supports standard PostgreSQL isolation levels:
62
62
  | `REPEATABLE READ` | Queries see a snapshot as of the start of the transaction. | Reports, consistent reads across multiple queries. |
63
63
  | `SERIALIZABLE` | Strictest level. Emulates serial execution. | Financial transactions, critical data integrity. |
64
64
 
65
- Note: `READ UNCOMMITTED` is technically accepted but behaves as `READ COMMITTED` in PostgreSQL.
65
+ > [!NOTE]
66
+ > Ignis only supports these three levels. `READ UNCOMMITTED` is **not** accepted — PostgreSQL treats it as `READ COMMITTED` anyway, so Ignis omits it to avoid confusion.
66
67
 
67
68
  ## Best Practices
68
69
 
@@ -218,7 +218,7 @@ this.bindRoute({
218
218
  For standard CRUD (Create, Read, Update, Delete) operations, `Ignis` provides a `ControllerFactory` that can generate a full-featured controller for any given entity. This significantly reduces boilerplate code.
219
219
 
220
220
  ```typescript
221
- // src/controllers/configuration.controller.ts (Example from @examples/vert)
221
+ // src/controllers/configuration/configuration.controller.ts (Example from @examples/vert)
222
222
  import { Configuration } from '@/models';
223
223
  import { ConfigurationRepository } from '@/repositories';
224
224
  import {
@@ -236,7 +236,7 @@ const _Controller = ControllerFactory.defineCrudController({
236
236
  controller: {
237
237
  name: 'ConfigurationController',
238
238
  basePath: BASE_PATH,
239
- isStrict: true,
239
+ isStrict: { path: true, requestSchema: true },
240
240
  },
241
241
  entity: () => Configuration, // Provide a resolver for your entity class
242
242
  });
@@ -19,7 +19,6 @@ To create a service, extend the `BaseService` class and inject the repositories
19
19
 
20
20
  ```typescript
21
21
  import { BaseService, inject } from '@venizia/ignis';
22
- import { getError } from '@venizia/ignis-helpers';
23
22
  import { ConfigurationRepository } from '../repositories';
24
23
  import { UserRepository } from '../repositories';
25
24
  import { LoggingService } from './logging.service'; // Example of another service