Skip to content

feat(js): public user-type accessors on SailsProgram and SailsService#1343

Open
ukint-vs wants to merge 4 commits intomasterfrom
feat/js-public-user-type-accessors-1320
Open

feat(js): public user-type accessors on SailsProgram and SailsService#1343
ukint-vs wants to merge 4 commits intomasterfrom
feat/js-public-user-type-accessors-1320

Conversation

@ukint-vs
Copy link
Copy Markdown
Member

@ukint-vs ukint-vs commented May 7, 2026

Closes #1320.

Summary

API additions (sails-js v2)

  • Adds SailsProgram.programTypes: ReadonlyMap<string, Type> for program-level user types declared in the IDL's program {…} block.
  • Adds SailsService.types: ReadonlyMap<string, Type> for the service's own types {…} block (declared-only — does not include types inherited via extends).
  • Re-exports the v2 IDL AST typings (Type, TypeDecl, ITypeStruct, ITypeEnum, ITypeAlias, IIdlDoc, IServiceUnit, …) from the top-level sails-js entry, so consumers can import type { Type } from 'sails-js' without the sails-js/types subpath.

Both maps are eager-built in the constructor and return an empty Map when the corresponding IDL block is absent. They are typed ReadonlyMap to block .set() at the type level; runtime mutation is not enforced.

For the merged extends-chain scope, consumers continue to use program.resolveInService(name, decl) or walk service.extends[base].types. The new service.types accessor intentionally does not surface inherited types — making each service's declared surface explicit and keeping the existing resolution semantics untouched.

Why

vara-wallet and other downstream consumers were reaching into SailsProgram._doc via as any to enumerate user types (see #1320 for the full writeup). That escape hatch breaks silently the moment a private field is renamed between betas, and consumers couldn't stay type-safe at the AST level because sails-js-types was only reachable through the sails-js/types subpath.

Test Coverage

10 new tests in js/test/idl-v2-types-accessors.test.ts covering every code path on the new surface:

[+] SailsProgram.programTypes
  ├── [★★★ TESTED] populated from program.types
  ├── [★★★ TESTED] empty Map when program block has no types
  ├── [★★★ TESTED] empty Map when no program block (services-only IDL)
  └── [★★★ TESTED] same Map instance on repeated access (eager build)

[+] SailsService.types
  ├── [★★★ TESTED] populated from service.types
  ├── [★★★ TESTED] empty Map when service has no types block
  ├── [★★★ TESTED] declared-only: types in service A absent from service B.types
  ├── [★★★ TESTED] declared-only: base types not pulled into derived via extends
  │                  + sanity check: child.extends.Base.types includes the base type
  │                  + sanity check: program.resolveInService still merges scope
  └── [★★★ TESTED] same Map instance on repeated access (eager build)

[+] Top-level type re-export
  └── [★★★ TESTED] `import type { Type, ITypeStruct } from 'sails-js'` typechecks

COVERAGE: 10/10 paths tested (100%)

Tests: 14 → 15 (+1 new file, 10 new test cases). 84 of 84 v2 tests pass on the merged state (10 new + 74 adjacent regression-coverage suites: idl-v2-parser-type-resolver, idl-v2-type-resolver, event-type-shape, route-idx-header-validation, partial-idl).

Pre-Landing Review

Five review passes ran before landing. All findings resolved.

Pass Findings Resolution
/plan-eng-review 4 design tradeoffs (F1 empty-program contract, F2 lazy-vs-eager, F3 test placement, F4 mutation contract honesty) All 4 resolved as documented in the plan file.
/review (gstack pre-landing) 1 INFORMATIONAL (sails-js-types resolution) Tracked in #1344 at the time, then fully closed for this PR by Codex's later finding.
/code-review-graph review-pr Graph reported "high risk" (488-node blast radius) False signal — graph counts transitive dependents, not behavioral delta. Real delta is additive (zero existing surface modified).
/simplify (3 parallel reviewers) 1 actionable: ISailsService interface missing types member Fixed in d47b2e95.
/codex review 1 P1: export type * from 'sails-js-types' ships unresolvable external module ref Fixed in c4696f41 by re-exporting from the bundled ./types.js instead. Issue #1344 updated to reflect 1/4 of the historical leaks closed; 3 pre-existing references remain in lib/sails-idl-v2.d.ts, lib/util.d.ts, lib/parser.d.ts and will be addressed by the build-side fix tracked in #1344.

Bot comment from gemini-code-assist[bot] (eager-vs-lazy Map build) replied with the F2 rationale; no code change needed (review thread r3201667845).

Plan Completion

Item from plan Status
Add SailsProgram.programTypes getter DONE — js/src/sails-idl-v2.ts:259
Add SailsService.types getter DONE — js/src/sails-idl-v2.ts:502
Add types to ISailsService interface DONE — js/src/sails-idl-v2.ts:25
Top-level export type * from sails-js DONE — js/src/index.ts:12 (via ./types.js)
New test file: idl-v2-types-accessors.test.ts DONE — 10 tests
CHANGELOG ## Unreleased entries DONE — 2 bullets added
JSDoc with declared-only contract on service.types DONE
JSDoc with empty-Map contract on programTypes DONE

COMPLETION: 8/8 DONE. No PARTIAL, no NOT DONE.

Documentation

js/CHANGELOG.md updated under ## Unreleased with two bullets covering the new accessors and the top-level type re-export (including the resolution path via bundled ./types.js). No README sync needed — the existing README documents only the sails-js/types subpath and that path still works exactly as before; the top-level alias is purely additive.

No version bump: js/package.json stays at 0.5.1 since this is part of the next unreleased.

Test plan

  • pnpm test test/idl-v2-types-accessors.test.ts — 10/10 pass
  • pnpm test for adjacent v2 suites — 74/74 pass (no regressions)
  • pnpm build — clean; lib/index.d.ts emits export type * from './types.js'; lib/types.d.ts is fully dts-bundled (241 lines, all 49 AST types inlined, zero external module references)
  • Type-level smoke check: import type { Type, ITypeStruct } from 'sails-js' typechecks at consumer install time without sails-js-types in node_modules
  • CI: lint + test green on every commit
  • Codex P1 (sails-js-types external ref in lib/index.d.ts) — fixed in this PR
  • Pre-existing unrelated leaks in lib/sails-idl-v2.d.ts, lib/util.d.ts, lib/parser.d.ts — out of scope for this PR, tracked in sails-js: published .d.ts files reference private sails-js-types module #1344

🤖 Generated with Claude Code

Closes #1320.

Adds `SailsProgram.programTypes` and `SailsService.types`, both
`ReadonlyMap<string, Type>`, so consumers can enumerate user types
declared in the v2 IDL without reaching into the private `_doc` field
via `as any`. `SailsService.types` is declared-only — it returns the
service's own `types {…}` block. For the merged extends-chain scope,
`program.resolveInService(name, decl)` and `service.extends[base].types`
already cover the use cases. Both maps are empty when the corresponding
IDL block is absent.

Also re-exports the v2 IDL AST typings (`Type`, `TypeDecl`, `ITypeStruct`,
`ITypeEnum`, `ITypeAlias`, `IIdlDoc`, `IServiceUnit`, etc.) from the
top-level `sails-js` entry via `export type * from 'sails-js-types'`,
so consumers can `import type { Type } from 'sails-js'` without the
`sails-js/types` subpath. The internal `sails-js-types` workspace
package stays `private: true`.

The new maps are built eagerly in the constructors. They are typed
`ReadonlyMap` to block `.set()` at compile time; runtime mutation is
not enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces public ReadonlyMap accessors for user types in SailsProgram and SailsService, and re-exports IDL AST types from the package root. A review comment suggests adopting a lazy indexing strategy for these type maps to improve performance and maintainability by avoiding unnecessary initialization overhead in the constructor.

Comment thread js/src/sails-idl-v2.ts
if (this._doc.program) {
this._typeResolver = new TypeResolver(this._doc.program.types ?? []);
}
this._programTypes = new Map((doc.program?.types ?? []).map((t) => [t.name, t]));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve maintainability and performance, and to address the duplication in type mapping logic, implement a lazy indexing strategy using a Map. This ensures O(1) lookup time for frequent access to the IDL tree while avoiding unnecessary initialization overhead in the constructor, as per the project's performance guidelines for large data structures.

private _typesMap?: Map<string, Type>;
private get typesMap(): Map<string, Type> {
  if (!this._typesMap) {
    this._typesMap = new Map((this.types ?? []).map(t => [t.name, t]));
  }
  return this._typesMap;
}
References
  1. For methods performing frequent lookups in large data structures (such as IDL trees), implement a lazy indexing strategy using a Map to achieve O(1) lookup time and avoid unnecessary initialization overhead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a deliberate eager-build choice, not an oversight. The tradeoff was weighed during plan review (F2) and resolved as eager:

  • The maps are O(N) over a small N (per-program/per-service user-type counts are typically <50). Build cost is negligible compared to the parse step that already runs to produce the IIdlDoc.
  • _doc is immutable post-parse (see existing comment at sails-idl-v2.ts:174–176). Lazy invalidation is a non-feature here.
  • Lazy adds a private _typesMap?: Map<string, Type> field plus a guard branch in every access. The maps are tiny — that's noise without payoff.
  • The existing lazy field on this class (_serviceTypeIndex at sails-idl-v2.ts:178) is lazy because it walks the extends chain across all services in one pass, which is genuine work. (service.types ?? []).map(t => [t.name, t]) from a single unit is a flat copy and isn't comparable.

The "performance guidelines" reference in the suggestion isn't from this repo — there's no project rule advocating lazy indexing for tiny user-type maps.

Going to leave the eager construction as-is.

@ukint-vs ukint-vs requested a review from vobradovich May 7, 2026 13:13
ukint-vs and others added 3 commits May 7, 2026 17:16
The new `SailsService.types` getter is reachable on the class but was
missing from the `ISailsService` read-surface interface. Surfaced by
/simplify code-quality review as a leaky abstraction (the interface
lists `functions`, `queries`, `events`, `extends`, `routeIdx` — `types`
belongs there too).

Two-line interface addition. No runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…package

The previous form, `export type * from 'sails-js-types'`, emitted an
unresolvable external module reference into the published `lib/index.d.ts`:
`sails-js-types` is `private: true` and only a devDependency of `sails-js`,
so downstream TypeScript consumers could fail to resolve the root
declaration even for normal value imports like `import { SailsProgram }
from 'sails-js'`.

Re-export from `./types.js` instead. That path resolves at consumer
install time to the rollup-`dts`-bundled `lib/types.d.ts`, which already
inlines every `sails-js-types` interface as a self-contained declaration.
No external module reference, no devDependency leak.

Caught by `codex review` as [P1].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sails-js v2: expose public accessors for program-level and per-service user types

2 participants