Jay-Action Contract References
Design Log #95 — Contract References and Compact Notation in .jay-action Files
Written for AI agents. See Log Methodology Note below for details.
Background
Design Log #92 introduced .jay-action files as metadata descriptors for server actions, using JSON Schema-style inline definitions for input/output types. Two problems emerged:
- Contract duplication: Action outputs often reference types from
.jay-contractfiles (e.g.,ProductCardViewState), but the schema is duplicated inline — incomplete and fragile. - Verbose format: The JSON Schema notation is verbose compared to the compact type notation already used in jay-html data scripts and contract files.
Problem
The .jay-action format should:
- Reference contract ViewState types instead of duplicating them
- Use the same compact type notation as jay-html (
string,number,enum(...), arrays as YAML lists,?for optional) - Reuse existing infrastructure:
resolveType, JayType system, enum parsing
Design
Revised .jay-action format
Replace JSON Schema with the compact jay-type notation. Add import: block for contract references.
Before (JSON Schema — current)
name: searchProducts
description: Search products...
inputSchema:
type: object
required:
- query
properties:
query:
type: string
description: Search query text
filters:
type: object
properties:
minPrice:
type: number
maxPrice:
type: number
collectionIds:
type: array
items:
type: string
sortBy:
type: string
enum: [relevance, price_asc, price_desc]
page:
type: number
pageSize:
type: number
outputSchema:
type: object
required:
- products
- totalCount
- hasMore
properties:
products:
type: array
items:
type: object
properties:
_id:
type: string
name:
type: string
# ... 20 more lines of duplicated contract schema
totalCount:
type: number
hasMore:
type: boolean
After (compact jay-type notation)
name: searchProducts
description: Search products...
import:
productCard: product-card.jay-contract
inputSchema:
query: string
filters?:
minPrice?: number
maxPrice?: number
collectionIds?: string[]
sortBy?: enum(relevance | price_asc | price_desc | name_asc | name_desc | newest)
page?: number
pageSize?: number
outputSchema:
products:
- productCard
totalCount: number
currentPage: number
totalPages: number
hasMore: boolean
priceAggregation:
minBound: number
maxBound: number
ranges:
- rangeId: string
label: string
minValue?: number
maxValue?: number
isSelected: boolean
Type notation rules
| Notation | Meaning | Example |
|---|---|---|
string, number, boolean |
Primitives | name: string |
enum(a | b | c) |
Enum type | sortBy?: enum(asc | desc) |
propName?: |
Optional property | filters?: ... |
YAML list - ... |
Array of objects | items: \n- id: string |
- importedName |
Array of imported type | products: \n- productCard |
importedName |
Imported contract type | product: productCard |
importedName? |
Nullable imported type | outputSchema: productCard? |
| Nested object | Inline object type | media: \n url: string |
import: block
import:
localAlias: contract-subpath.jay-contract
- Key is a local alias used in type expressions
- Value is the contract subpath (same format as
plugin.yamlandpackage.jsonexports) - The compiler resolves the import to generate a TS import statement
- At runtime (AI agent), the contract schema is inlined by the materializer
Nullable types
For top-level nullable outputs (e.g., getProductBySlug returning ProductCardViewState | null):
outputSchema: productCard?
Generates: export type GetProductBySlugOutput = ProductCardViewState | null
Generated .d.ts example
From the compact notation above:
import { ProductCardViewState } from '../contracts/product-card.jay-contract';
export interface SearchProductsInput {
query: string;
filters?: {
minPrice?: number;
maxPrice?: number;
collectionIds?: string[];
};
sortBy?: 'relevance' | 'price_asc' | 'price_desc' | 'name_asc' | 'name_desc' | 'newest';
page?: number;
pageSize?: number;
}
export interface SearchProductsOutput {
products: Array<ProductCardViewState>;
totalCount: number;
currentPage: number;
totalPages: number;
hasMore: boolean;
priceAggregation: {
minBound: number;
maxBound: number;
ranges: Array<{
rangeId: string;
label: string;
minValue?: number;
maxValue?: number;
isSelected: boolean;
}>;
};
}
Reuse of existing infrastructure
| Component | Existing | Reuse for .jay-action |
|---|---|---|
resolvePrimitiveType() |
compiler-shared |
Resolve string, number, boolean → JayAtomicType |
parseIsEnum() / parseEnumValues() |
expression-compiler.ts |
Parse enum(...) → JayEnumType |
JayObjectType |
compiler-shared/jay-type.ts |
Objects with properties |
JayArrayType |
compiler-shared/jay-type.ts |
Array wrapping |
JayImportedType |
compiler-shared/jay-type.ts |
Contract references (alias + nullable) |
JayOptionalType (new) |
compiler-shared/jay-type.ts |
Wrap any type to mark optional |
New code:
JayOptionalTypeincompiler-shared/jay-type.ts— wrapper type for optional propertiesresolveActionType()inaction-parser.ts— parses compact notation into JayType (handles?, imports,type[])- Action compiler in
action-compiler.ts— renders JayType → TypeScript with contract imports, inline objects, union enums - Compact → JSON Schema in
action-metadata.ts(runtime) — converts compact notation to JSON Schema at materialization time
Implementation Plan
Phase 1: Update parser and compiler
- Rewrite
action-parser.tsto parse compact notation withimport:,?optional, and YAML-based types - Rewrite
action-compiler.tsto emit TypeScript from JayType (with import statements for contracts) - Add JayType → JSON Schema utility for runtime use
- Update tests
Phase 2: Migrate .jay-action files
- Convert all existing
.jay-actionfiles (gemini-agent, wix-data, wix-stores, wix-stores-v1) to the new format - Verify generated
.d.tsfiles match expected output
Phase 3: Runtime integration
- Update action metadata resolution to parse compact format
- Convert to JSON Schema at materialization time for AI agent tool descriptions
More Examples
✅ Simple action with no imports
name: getCategories
description: Get store categories
inputSchema: {}
outputSchema:
categories:
- _id: string
slug: string
title: string
itemCount: number
✅ Nullable contract return
name: getProductBySlug
description: Get product by slug
import:
productCard: product-card.jay-contract
inputSchema:
slug: string
outputSchema: productCard?
✅ Mixed inline and contract types
name: searchProducts
description: Search products
import:
productCard: product-card.jay-contract
inputSchema:
query: string
pageSize?: number
outputSchema:
products:
- productCard
totalCount: number
hasMore: boolean
✅ Array output (no wrapper object)
name: getCollections
description: Get collections
inputSchema: {}
outputSchema:
- _id: string
name: string
slug: string
productCount: number
Trade-offs
| Aspect | Pro | Con |
|---|---|---|
| Compact notation | Much shorter files, consistent with jay-html | Breaking change to format just introduced |
| JayType reuse | Single type system across framework | Added JayOptionalType to shared types |
| Contract imports | Single source of truth for ViewState types | Coupling between action and contract files |
| JSON Schema at runtime | Clean separation (define compact, export JSON Schema) | Extra conversion step |
? for optional |
More ergonomic than required arrays |
Slightly extends the notation vs jay-html data scripts |
Implementation Results
What was implemented
Phase 1 — Parser & Compiler (compiler-jay-html)
- Added
JayOptionalTypetocompiler-shared/jay-type.ts— a wrapper type (JayOptionalType(innerType)) that marks anyJayTypeas optional. Optional properties inJayObjectType.propsare wrapped:{ limit: new JayOptionalType(JayNumber) }. - Rewrote
action-parser.tsto produce JayType trees. UsesresolvePrimitiveType(),parseIsEnum()/parseEnumValues()from shared infra. NewresolveActionType()handles?optional, contract imports (JayImportedType), andtype[]array shorthand. - Rewrote
action-compiler.tswithContractResolverinterface. Walks JayType tree, collectsJayImportedTypenodes, resolves to ViewState names and import paths. Action-specific renderer: inline objects, union enums,Array<primitive>. - Updated
definitions-compiler.ts(rollup plugin) with contract resolver that searches siblingcontracts/directory and parent directories for.jay-contractfiles. ESM-compatible (norequire()/glob).
Phase 1b — Runtime (stack-server-runtime)
- Refactored
action-metadata.tsto reuse the compiler'sparseAction()fromcompiler-jay-html(which produces JayType trees), then convert to JSON Schema viajayTypeToJsonSchema()fromcompiler-shared. Eliminated the duplicate compact-to-JSON-Schema conversion logic. - Added
jayTypeToJsonSchema()converter incompiler-shared/lib/jay-type-to-json-schema.ts— walks JayType tree and produces JSON Schema properties. Handles atomic, enum, array, object, imported (→{ type: 'object', description: 'Contract: ...' }), and optional (unwraps to inner type, excludes fromrequired).
Phase 2 — Migrated all 11 .jay-action files
gemini-agent: 2 files (send-message, submit-tool-results)wix-data: 3 files (query-items, get-item-by-slug, get-categories)wix-stores: 3 files (search-products, get-product-by-slug, get-categories)wix-stores-v1: 3 files (search-products, get-product-by-slug, get-collections)
Deviations from design
New
JayOptionalTypewrapper: AddedJayOptionalTypetocompiler-shared/jay-type.tsas a wrapper type —JayOptionalType(innerType: JayType). Optional properties inJayObjectType.propsare wrapped:{ limit: new JayOptionalType(JayNumber) }. The compiler unwraps to renderlimit?: number. This is cleaner than tracking optional as a side-channelSet<string>on the parent object — the optional marker lives with the type itself, composable likeJayArrayTypeandJayPromiseType.Action-specific TypeScript renderer: The existing
generateTypes()/renderInterface()injay-html-compile-types.tsrenders enums asexport enum, uses commas between properties, extracts child interfaces as separate named types, and doesn't supportArray<primitive>. For actions we need inline union enums ('a' | 'b'), semicolons, inline objects, and array-of-primitive support. Wrote an action-specific renderer that consumes JayType but renders for action.d.tsoutput.No direct
resolveType()reuse: The existingresolveType()doesn't handle?optional keys, contract imports, ortype[]array shorthand. Wrote a parallelresolveActionType()in the action parser that usesresolvePrimitiveType()andparseIsEnum()/parseEnumValues()from the shared infrastructure but adds action-specific features.Contract references via
JayImportedType: UsedJayImportedType(alias, JayUnknown, isOptional)wherealiasis the import key (e.g., "productCard"),JayUnknownis a placeholder (the actual type is resolved at compile time viaContractResolver), andisOptionalindicates nullable (?).Enum identifiers with hyphens: The PEG grammar's
Identifierrule doesn't support hyphens (e.g.,tool-calls). Changed gemini-agent'stypefield fromenum(response | tool-calls)to plainstring. Non-breaking since the enum was only informational.Empty objects:
{}in YAML (e.g.,pageState: {},filter: {}) generatesRecord<string, unknown>in TypeScript. Consistent with how unknown-shape objects should be typed.
Test results
- compiler-shared: 11 jayTypeToJsonSchema tests passing
- compiler-jay-html: 540 passed (20 test files), including 26 action tests
- stack-server-runtime: 89 passed (10 test files), including 13 action-metadata tests
- All wix packages build successfully with correct generated
.d.tsfiles - Contract import resolution verified:
import { ProductCardViewState } from '../contracts/product-card.jay-contract'
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.