Skip to content

Commit 184e479

Browse files
committed
feat: TypeScript/npm identity derivation layer
Add DeriveNpmPackage and DeriveTsImport to derive scoped npm package names (@<org>/<domain>-<name>-<line>-proto) from API identity. Extend all display, manifest, and unlink surfaces. Promote TypeScript from Planned to Tier 2 in docs.
1 parent 7d5e88d commit 184e479

10 files changed

Lines changed: 262 additions & 14 deletions

File tree

cmd/apx/commands/unlink.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func unlinkAction(cmd *cobra.Command, args []string) error {
4646
printGoUnlinkHint(modulePath)
4747
printPythonUnlinkHint(modulePath)
4848
printJavaUnlinkHint(modulePath)
49+
printTsUnlinkHint(modulePath)
4950
ui.Success("Unlinked %s - now using released module", modulePath)
5051
return nil
5152
}
@@ -84,3 +85,16 @@ func printJavaUnlinkHint(modulePath string) {
8485
coords := config.DeriveMavenCoords(cfg.Org, api)
8586
ui.Info("Java: Add %s:<version> to your pom.xml", coords)
8687
}
88+
89+
func printTsUnlinkHint(modulePath string) {
90+
cfg, _ := config.LoadRaw("")
91+
if cfg == nil || cfg.Org == "" {
92+
return
93+
}
94+
api, err := config.ParseAPIID(modulePath)
95+
if err != nil {
96+
return
97+
}
98+
npmPkg := config.DeriveNpmPackage(cfg.Org, api)
99+
ui.Info("TypeScript: Run 'npm install %s' to install the released package", npmPkg)
100+
}

docs/app-repos/local-development.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,46 @@ Key differences from Go and Python:
284284

285285
---
286286

287+
## TypeScript Development Loop
288+
289+
For TypeScript consumers, APX publishes schema packages to npm with scoped package names:
290+
291+
```bash
292+
# 1. Add dependency
293+
apx add proto/payments/ledger/v1@v1.2.3
294+
295+
# 2. Install npm package
296+
npm install @acme/payments-ledger-v1-proto
297+
298+
# 3. Import in your TypeScript code
299+
# import { LedgerService } from "@acme/payments-ledger-v1-proto";
300+
301+
# 4. Build and test
302+
npm run build && npm test
303+
```
304+
305+
For local development before schemas are released:
306+
307+
```bash
308+
# Link schema packages locally (planned)
309+
apx link typescript
310+
311+
# npm resolves from local link
312+
npm run build
313+
314+
# When ready for released packages
315+
apx unlink proto/payments/ledger/v1
316+
npm install @acme/payments-ledger-v1-proto
317+
```
318+
319+
Key differences from Go and Python:
320+
321+
- **npm-native resolution** -- `npm link` for local dev, npm registry for released packages
322+
- **Scoped packages** -- all packages use `@<org>/` scope for namespace isolation
323+
- **`-proto` suffix** -- distinguishes schema packages from application packages
324+
325+
---
326+
287327
## Adding Dependencies
288328

289329
To use schemas released by other teams:

docs/cli-reference/index.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ APX commands are organized into logical categories:
1515
- `apx fetch` - Download toolchain
1616
- `apx gen` - Generate code with canonical imports
1717
- `apx sync` - Update go.work overlays
18-
- `apx link` - Link Python overlays for local dev
18+
- `apx link` - Link Python/TypeScript overlays for local dev
1919
- `apx unlink` - Remove overlays for released APIs
2020
:::
2121

@@ -142,7 +142,14 @@ type Service struct {
142142
| `proto/users/profile/v1` | `com.<org>.apis` | `users-profile-v1-proto` | `com.<org>.apis.users.profile.v1` |
143143
| `proto/orders/v1` (3-part) | `com.<org>.apis` | `orders-v1-proto` | `com.<org>.apis.orders.v1` |
144144

145-
**TypeScript** — Planned. npm scope and package name derivation will follow the same deterministic pattern.
145+
**APX API Path → TypeScript / npm**
146+
147+
| APX API Path | npm Package |
148+
|---|---|
149+
| `proto/payments/ledger/v1` | `@<org>/payments-ledger-v1-proto` |
150+
| `proto/users/profile/v1` | `@<org>/users-profile-v1-proto` |
151+
| `proto/orders/v1` (3-part) | `@<org>/orders-v1-proto` |
152+
| `openapi/billing/invoices/v1` | `@<org>/billing-invoices-v1-proto` |
146153

147154
**Local Overlay Paths**
148155

docs/concepts/multi-language-strategy.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ APX provides a symmetric developer experience across languages. The core princip
1616

1717
| Concern | Go | Python | Java | TypeScript |
1818
|---------|-----|--------|------|------------|
19-
| Published artifact | Go module | sdist / wheel | Schema zip / jar | Planned |
20-
| Local overlay | `go.work use` | `pip install -e` | `mvn install:install-file` | Planned |
21-
| Resolution mechanism | go.work -> go.mod | pkgutil namespace | Maven dependency resolution | Planned |
22-
| Code generation | `apx gen go` | `apx gen python` | `mvn generate-sources` | Planned |
23-
| Dev command | `apx sync` | `apx link python` | `apx link java` (planned) | Planned |
24-
| Unlink hint | `go get ...` | `pip install ...` | pom.xml dependency | Planned |
25-
| Status | **Tier 1** | **Tier 2** | **Tier 2** | **Planned** |
19+
| Published artifact | Go module | sdist / wheel | Schema zip / jar | npm package |
20+
| Local overlay | `go.work use` | `pip install -e` | `mvn install:install-file` | `npm link` |
21+
| Resolution mechanism | go.work -> go.mod | pkgutil namespace | Maven dependency resolution | npm / workspace |
22+
| Code generation | `apx gen go` | `apx gen python` | `mvn generate-sources` | `apx gen typescript` (planned) |
23+
| Dev command | `apx sync` | `apx link python` | `apx link java` (planned) | `apx link typescript` (planned) |
24+
| Unlink hint | `go get ...` | `pip install ...` | pom.xml dependency | `npm install ...` |
25+
| Status | **Tier 1** | **Tier 2** | **Tier 2** | **Tier 2** |
2626

2727
### Tier definitions
2828

@@ -42,6 +42,7 @@ Given `org=acme` and API path `proto/payments/ledger/v1`:
4242
| Python | Import | `acme_apis.payments.ledger.v1` |
4343
| Java | Maven coords | `com.acme.apis:payments-ledger-v1-proto` |
4444
| Java | Package | `com.acme.apis.payments.ledger.v1` |
45+
| TypeScript | npm package | `@acme/payments-ledger-v1-proto` |
4546

4647
## Go Workflow
4748

@@ -70,6 +71,17 @@ Java uses Maven's dependency resolution and code generation phases:
7071

7172
Java developers never interact with Go modules or `go.work`. The Maven coordinate system provides a complete, self-contained experience.
7273

73-
## TypeScript Workflow (Planned)
74+
## TypeScript Workflow
7475

75-
TypeScript support will follow the same pattern: npm packages as the published artifact, local linking via `npm link` or workspace references, and identity derivation for npm scope/package names.
76+
TypeScript uses npm packages as the published artifact with scoped package names:
77+
78+
1. **Producer** releases schema artifacts to an npm registry via APX's release pipeline. The npm package name is deterministically derived: `@<org>/<domain>-<name>-<line>-proto`.
79+
2. **Consumer** installs the package using `npm install @<org>/<domain>-<name>-<line>-proto`.
80+
3. For local development, `apx link typescript` (planned) links local schema artifacts via `npm link`, allowing resolution without a remote registry.
81+
4. `apx unlink` removes the local link; `npm install @<org>/<pkg>` adds the released package. Import paths stay the same.
82+
83+
TypeScript developers import generated types directly from the npm package name:
84+
85+
```typescript
86+
import { LedgerService } from "@acme/payments-ledger-v1-proto";
87+
```

docs/dependencies/code-generation.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ apx gen <lang> [path]
1010

1111
The `apx gen` command reads your schemas from `internal/apis/`, generates code using format-specific toolchains (e.g. Buf for protobuf), and writes the output to `internal/gen/<lang>/` with canonical module paths.
1212

13-
**Supported languages:** `go`, `python`, `java`
13+
**Supported languages:** `go`, `python`, `java`, `typescript`
1414

1515
---
1616

@@ -182,6 +182,46 @@ This mirrors Go's `go.work` overlay and Python's `pip install -e` — same ident
182182

183183
---
184184

185+
## TypeScript Generation
186+
187+
TypeScript follows the same schema-first pattern. APX derives scoped npm package names from the API identity:
188+
189+
| Component | Pattern | Example |
190+
|-----------|---------|---------|
191+
| npm package | `@<org>/<domain>-<name>-<line>-proto` | `@acme/payments-ledger-v1-proto` |
192+
193+
The `-proto` suffix distinguishes schema packages from application packages.
194+
195+
### Consumer Workflow
196+
197+
Install the schema package from npm:
198+
199+
```bash
200+
npm install @acme/payments-ledger-v1-proto
201+
```
202+
203+
Import generated types in your TypeScript code:
204+
205+
```typescript
206+
import { LedgerService } from "@acme/payments-ledger-v1-proto";
207+
```
208+
209+
### Local Development
210+
211+
For local development before schemas are released, use `apx link typescript` (planned) to create npm links:
212+
213+
```bash
214+
# Generate and link locally
215+
apx link typescript # planned — creates npm link
216+
217+
# npm resolves from local link
218+
npm run build
219+
```
220+
221+
This mirrors Go's `go.work`, Python's `pip install -e`, and Java's `~/.m2` — same identity in dev and prod.
222+
223+
---
224+
185225
## Flags
186226

187227
| Flag | Type | Default | Description |

internal/config/identity.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,31 @@ func DeriveJavaPackage(org string, api *APIIdentity) string {
239239
return strings.Join(parts, ".")
240240
}
241241

242+
// DeriveNpmPackage computes the scoped npm package name for an API.
243+
//
244+
// Rules:
245+
// - Pattern: @<org>/<domain>-<name>-<line>-proto (4-part) or @<org>/<name>-<line>-proto (3-part)
246+
// - Lowercased, hyphens join path segments, -proto suffix
247+
// - Example: org="acme", proto/payments/ledger/v1 → "@acme/payments-ledger-v1-proto"
248+
// - Example: org="acme", proto/orders/v1 (3-part) → "@acme/orders-v1-proto"
249+
func DeriveNpmPackage(org string, api *APIIdentity) string {
250+
scope := strings.ToLower(org)
251+
var parts []string
252+
if api.Domain != "" {
253+
parts = append(parts, strings.ToLower(api.Domain))
254+
}
255+
parts = append(parts, strings.ToLower(api.Name))
256+
parts = append(parts, strings.ToLower(api.Line))
257+
parts = append(parts, "proto")
258+
return "@" + scope + "/" + strings.Join(parts, "-")
259+
}
260+
261+
// DeriveTsImport returns the TypeScript import path for an API.
262+
// In TypeScript, the import path is the npm package name itself.
263+
func DeriveTsImport(org string, api *APIIdentity) string {
264+
return DeriveNpmPackage(org, api)
265+
}
266+
242267
// pep440PreRe matches SemVer pre-release tags: alpha, beta, rc with optional dot-separator.
243268
var pep440PreRe = regexp.MustCompile(`-(alpha|beta|rc)\.?(\d+)`)
244269

@@ -329,6 +354,10 @@ func DeriveLanguageCoordsWithRoot(sourceRepo, importRoot, org string, api *APIId
329354
Module: DeriveMavenCoords(org, api),
330355
Import: DeriveJavaPackage(org, api),
331356
}
357+
coords["typescript"] = LanguageCoords{
358+
Module: DeriveNpmPackage(org, api),
359+
Import: DeriveTsImport(org, api),
360+
}
332361
}
333362

334363
return coords, nil
@@ -410,6 +439,10 @@ func FormatIdentityReport(api *APIIdentity, source *SourceIdentity, release *Rel
410439
sb.WriteString(fmt.Sprintf("Java pkg: %s\n", javaCoords.Import))
411440
}
412441

442+
if tsCoords, ok := langs["typescript"]; ok {
443+
sb.WriteString(fmt.Sprintf("npm: %s\n", tsCoords.Module))
444+
}
445+
413446
return sb.String()
414447
}
415448

internal/config/identity_test.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,19 +491,23 @@ func TestDeriveLanguageCoordsWithRoot(t *testing.T) {
491491
assert.Equal(t, "go.acme.dev/apis/proto/payments/ledger", coords["go"].Module)
492492
assert.Equal(t, "go.acme.dev/apis/proto/payments/ledger/v1", coords["go"].Import)
493493

494-
// With org — Python and Java coords are populated.
494+
// With org — Python, Java, and TypeScript coords are populated.
495495
coords, err = DeriveLanguageCoordsWithRoot("github.com/acme/apis", "", "acme", api)
496496
require.NoError(t, err)
497497
assert.Equal(t, "acme-payments-ledger-v1", coords["python"].Module)
498498
assert.Equal(t, "acme_apis.payments.ledger.v1", coords["python"].Import)
499499
assert.Equal(t, "com.acme.apis:payments-ledger-v1-proto", coords["java"].Module)
500500
assert.Equal(t, "com.acme.apis.payments.ledger.v1", coords["java"].Import)
501+
assert.Equal(t, "@acme/payments-ledger-v1-proto", coords["typescript"].Module)
502+
assert.Equal(t, "@acme/payments-ledger-v1-proto", coords["typescript"].Import)
501503

502-
// Without org — Java coords should be absent.
504+
// Without org — Java and TypeScript coords should be absent.
503505
coords, err = DeriveLanguageCoordsWithRoot("github.com/acme/apis", "", "", api)
504506
require.NoError(t, err)
505507
_, hasJava := coords["java"]
506508
assert.False(t, hasJava, "java coords should be absent when org is empty")
509+
_, hasTs := coords["typescript"]
510+
assert.False(t, hasTs, "typescript coords should be absent when org is empty")
507511
}
508512

509513
func TestBuildIdentityBlockWithRoot(t *testing.T) {
@@ -734,6 +738,57 @@ func TestDeriveJavaPackage(t *testing.T) {
734738
}
735739
}
736740

741+
// ---------------------------------------------------------------------------
742+
// TypeScript / npm identity derivation
743+
// ---------------------------------------------------------------------------
744+
745+
func TestDeriveNpmPackage(t *testing.T) {
746+
tests := []struct {
747+
name string
748+
org string
749+
api *APIIdentity
750+
want string
751+
}{
752+
{
753+
name: "4-part with domain",
754+
org: "acme",
755+
api: &APIIdentity{Format: "proto", Domain: "payments", Name: "ledger", Line: "v1"},
756+
want: "@acme/payments-ledger-v1-proto",
757+
},
758+
{
759+
name: "3-part no domain",
760+
org: "acme",
761+
api: &APIIdentity{Format: "proto", Domain: "", Name: "orders", Line: "v1"},
762+
want: "@acme/orders-v1-proto",
763+
},
764+
{
765+
name: "uppercase org normalized",
766+
org: "ACME",
767+
api: &APIIdentity{Format: "proto", Domain: "Payments", Name: "Ledger", Line: "v2"},
768+
want: "@acme/payments-ledger-v2-proto",
769+
},
770+
{
771+
name: "hyphenated org",
772+
org: "acme-corp",
773+
api: &APIIdentity{Format: "proto", Domain: "payments", Name: "ledger", Line: "v1"},
774+
want: "@acme-corp/payments-ledger-v1-proto",
775+
},
776+
}
777+
for _, tt := range tests {
778+
t.Run(tt.name, func(t *testing.T) {
779+
assert.Equal(t, tt.want, DeriveNpmPackage(tt.org, tt.api))
780+
})
781+
}
782+
}
783+
784+
func TestDeriveTsImport(t *testing.T) {
785+
// In TypeScript, the import path IS the npm package name.
786+
api := &APIIdentity{Format: "proto", Domain: "payments", Name: "ledger", Line: "v1"}
787+
npmPkg := DeriveNpmPackage("acme", api)
788+
tsImport := DeriveTsImport("acme", api)
789+
assert.Equal(t, npmPkg, tsImport)
790+
}
791+
737792
func TestNormalizePEP440Version(t *testing.T) {
738793
tests := []struct {
739794
input string

internal/publisher/manifest.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type ReleaseManifest struct {
4747
PythonImport string `yaml:"python_import,omitempty" json:"python_import,omitempty"`
4848
MavenCoords string `yaml:"maven_coords,omitempty" json:"maven_coords,omitempty"`
4949
JavaPackage string `yaml:"java_package,omitempty" json:"java_package,omitempty"`
50+
NpmPackage string `yaml:"npm_package,omitempty" json:"npm_package,omitempty"`
5051

5152
// Tag
5253
Tag string `yaml:"tag" json:"tag"`
@@ -138,6 +139,10 @@ func NewManifest(
138139
m.JavaPackage = javaCoords.Import
139140
}
140141

142+
if tsCoords, ok := langs["typescript"]; ok {
143+
m.NpmPackage = tsCoords.Module
144+
}
145+
141146
return m
142147
}
143148

@@ -233,6 +238,9 @@ func FormatManifestReport(m *ReleaseManifest) string {
233238
lines = append(lines, fmt.Sprintf("Maven: %s", m.MavenCoords))
234239
lines = append(lines, fmt.Sprintf("Java pkg: %s", m.JavaPackage))
235240
}
241+
if m.NpmPackage != "" {
242+
lines = append(lines, fmt.Sprintf("npm: %s", m.NpmPackage))
243+
}
236244
if m.PRURL != "" {
237245
lines = append(lines, fmt.Sprintf("PR URL: %s", m.PRURL))
238246
if m.PRNumber != 0 {

internal/publisher/manifest_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ func TestNewManifest_WithAllCoords(t *testing.T) {
233233
Module: "com.acme.apis:payments-ledger-v1-proto",
234234
Import: "com.acme.apis.payments.ledger.v1",
235235
},
236+
"typescript": {
237+
Module: "@acme/payments-ledger-v1-proto",
238+
Import: "@acme/payments-ledger-v1-proto",
239+
},
236240
}
237241

238242
m := NewManifest(api, source, langs, "v1.0.0", "github.com/acme/apis")
@@ -245,9 +249,13 @@ func TestNewManifest_WithAllCoords(t *testing.T) {
245249
assert.Equal(t, "com.acme.apis:payments-ledger-v1-proto", m.MavenCoords)
246250
assert.Equal(t, "com.acme.apis.payments.ledger.v1", m.JavaPackage)
247251

252+
// TypeScript coords
253+
assert.Equal(t, "@acme/payments-ledger-v1-proto", m.NpmPackage)
254+
248255
report := FormatManifestReport(m)
249256
assert.Contains(t, report, "Py dist: acme-payments-ledger-v1")
250257
assert.Contains(t, report, "Py import: acme_apis.payments.ledger.v1")
251258
assert.Contains(t, report, "Maven: com.acme.apis:payments-ledger-v1-proto")
252259
assert.Contains(t, report, "Java pkg: com.acme.apis.payments.ledger.v1")
260+
assert.Contains(t, report, "npm: @acme/payments-ledger-v1-proto")
253261
}

0 commit comments

Comments
 (0)