npm package: @kaspernj/api-maker
Description: My new module
Author: kaspernj
Latest version: 1.0.2156

# @kaspernj/api-maker

Client-side library for ApiMaker-powered Rails APIs. It provides model classes, collections, and UI helpers for web and React Native apps, backed by the JSON payloads generated by the ApiMaker server gem.

## Installation

```bash
npm install @kaspernj/api-maker
```

When working on this package locally, `npm install` uses the checked-in [`.npmrc`](/home/dev/Development/api_maker/npm-api-maker/.npmrc) with `legacy-peer-deps=true`. This keeps the ESLint 10 toolchain installable until `eslint-plugin-react` publishes an ESLint 10 peer range.

Peer-provided runtime packages that `npm-api-maker` imports in its own source, tests, or linked local/CI builds stay installed in this repo's `devDependencies` as well. For example, `react-native-vector-icons`, `flash-notifications`, `history`, and `i18n-on-steroids` are still peer-facing for consumers, but they must also exist locally when webpack, ESLint, or Jest resolves modules from the package directory itself.

## Quick start

ApiMaker models are generated from `model-recipes.json` (usually emitted by the Rails gem). Import the models and set up the shared config before making requests.

```js
import config from "@kaspernj/api-maker/build/config"
import Models from "@kaspernj/api-maker/build/models"
import history from "./history"
import routes from "./routes"

const {Account, Project, User} = Models

config.setHistory(history)
config.setLinkTo((path) => history.push(path))
config.setNavigation({
  navigate: (routeName, params) => history.push(routes[routeName](params))
})
config.setRoutes(routes)
config.setCurrenciesCollection([
  ["US Dollar", "usd"],
  ["Euro", "eur"]
])

const users = await User.ransack({name_cont: "sam"}).per(20).toArray()
const user = await User.find("a0f3842b-1e4c-4e9d-8f2d-cd021e5a9b6a")
```

## Models and collections

Models are generated from the server recipes and inherit `BaseModel`. Collections let you compose queries fluently and then execute them with `toArray()`.

For generated model source shape, prefer `export default class Foo {}` when possible; only use class expressions when explicitly required.

```js
const activeProjects = await Project
  .ransack({state_eq: "active"})
  .preload(["owner"])
  .sort("created_at desc")
  .toArray()

const firstProject = await Project.ransack({name_cont: "api"}).first()
const totalCount = await Project.ransack().count()
```

## Relationship loading and preloading

Relationship accessors are generated from the recipe. For a relationship named `account`, you get:

- `user.account()` to read a cached relationship.
- `user.loadAccount()` to fetch it from the API.

Reading a relationship before it is loaded raises `NotLoadedError` unless the model is new.

### Requesting preloads from the server

Use `preload` on a collection to have the server include related records in the response. ApiMaker stores those models in the relationship cache automatically.

```js
const users = await User
  .ransack()
  .preload(["account", "roles"])
  .toArray()

users[0].account()
```

### Manually preloading with `preloadRelationship`

When you already have related model instances (for example after creating records or from another response), you can mark a relationship as loaded without another request.

```js
const user = await User.find("a0f3842b-1e4c-4e9d-8f2d-cd021e5a9b6a")
const account = await Account.find("f6c0b2b3-0a37-4e6e-ae3a-2e6a38e3953d")

user.preloadRelationship("account", account)
user.account()
```

`preloadRelationship` accepts:

- A single model (for `belongs_to` / `has_one` relationships).
- An array of models (for `has_many` relationships).
- `null` when the relationship is known to be empty.

The relationship name is stored in `snake_case`, so `preloadRelationship("Account", account)` and `preloadRelationship("account", account)` are equivalent. After preloading, both `account()` and `loadAccount()` return the cached value without hitting the network.

## Configuration notes

`config` is a shared singleton. Some helpers (forms, routing, navigation, and money inputs) require config values like `history`, `linkTo`, `navigation`, and `currenciesCollection` to be set. If a required value is missing, the getter throws an error to avoid silent failures.

### Websocket requests

ApiMaker can send commands and services over ActionCable instead of the HTTP commands endpoint:

```js
import config from "@kaspernj/api-maker/build/config.js"

config.setWebsocketRequests(true)
```

This keeps request/response traffic on the shared websocket connection and lets the client reuse websocket-side request deduping and response caching.

When a command runs over websocket, the returned value is an awaitable command handle. It behaves like a promise, so existing code still works:

```js
const result = await User.customCommand({message: "Hello world"})
```

The same handle also exposes progress and log hooks for long-running commands:

```js
const command = User.customCommand({message: "Hello world"})

command.onReceived(() => console.log("received"))
command.onProgress(({progress, count, total}) => console.log(progress, count, total))
command.onLog((message) => console.log(message))

const result = await command
const latestProgress = await command.progress()
const logs = command.logs()
```

On the Ruby side, websocket-backed commands and services can report progress through `current_command`:

```ruby
current_command.total = 10
current_command.log("Starting")
current_command.increment!
current_command.progress = 0.5
```

### Waiting for websocket requests to go idle

For app/test cleanup flows that need to avoid leaking in-flight websocket command work into later state, use the realtime reset helper that waits for pending websocket requests before disconnecting the realtime runtime:

```js
import {waitForRealtimeRuntimeIdleAndReset} from "@kaspernj/api-maker/build/reset-realtime-runtime-state"

await waitForRealtimeRuntimeIdleAndReset({timeoutMs: 5000})
```

The synchronous default export still exists for immediate resets where waiting is not required:

```js
import resetRealtimeRuntimeState from "@kaspernj/api-maker/build/reset-realtime-runtime-state"

resetRealtimeRuntimeState()
```

## Realtime model events

For model-specific websocket events in React/ShapeComponent UI code, prefer `useModelEvent` over manual `ModelEvents.connect(...)` subscriptions. The hook handles subscription lifecycle automatically and avoids leaked listeners.

```js
import useModelEvent from "@kaspernj/api-maker/build/use-model-event.js"

useModelEvent(projectEnvironmentInstance, "termination_job_status", ({args}) => {
  console.log(args)
})
```

Related hooks:

- `useUpdatedEvent(model, onUpdated)`
- `useDestroyedEvent(model, onDestroyed)`
- `useCreatedEvent(ModelClass, onCreated)`
- `useModelClassEvent(ModelClass, "custom_event", onEvent)`

## ApiMakerTable date formatting

`ApiMakerTable` supports date formatting in two ways:

- i18n format-name strings (existing behavior)
- formatter callbacks (custom behavior)

Use `defaultDateFormatName` for date values and `defaultDateTimeFormatName` for datetime values.

```jsx
<ApiMakerTable
  defaultDateFormatName="date.formats.default"
  defaultDateTimeFormatName="time.formats.default"
  modelClass={User}
/>
```

You can also pass callbacks when you need country-aware or app-specific formatting:

```jsx
<ApiMakerTable
  defaultDateFormatName={({value}) => formatByCountry(value, "date")}
  defaultDateTimeFormatName={({value}) => formatByCountry(value, "date-with-time")}
  modelClass={User}
/>
```

Callback arguments:

- `value`: the `Date` instance being rendered
- `apiMakerType`: `"date"` or `"time"`
- `column`: the column definition object
- `model`: the current row model
- `table`: the table component instance
- `l`: ApiMaker i18n formatter helper (for fallback/interop)

## AttributeRows date formatting

`AttributeRows`/`AttributeRow` support the same date-format customization pattern as `ApiMakerTable`.

```jsx
<AttributeRows
  attributes={["createdAt"]}
  defaultDateFormatName="date.formats.default"
  defaultDateTimeFormatName="time.formats.default"
  model={user}
/>
```

You can also pass formatter callbacks:

```jsx
<AttributeRows
  attributes={["createdAt"]}
  defaultDateFormatName={({value}) => formatByCountry(value, "date")}
  defaultDateTimeFormatName={({value}) => formatByCountry(value, "date-with-time")}
  model={user}
/>
```

Callback arguments:

- `value`: the `Date` instance being rendered
- `apiMakerType`: `"date"` or `"time"`
- `attribute`: the attribute metadata object (when present)
- `model`: the current model instance
- `l`: ApiMaker i18n formatter helper (for fallback/interop)

## API reference

ApiMaker generates model classes from your recipes. The classes inherit `BaseModel` and expose relationship helpers based on your server-side associations.

### Relationship helpers (generated)

For a relationship named `account`, the model class gets:

- `user.account()` to read the cached relationship.
- `user.loadAccount()` to fetch it from the API.

For `has_many`, the getter returns a `Collection` for that relationship:

```js
const user = await User.find("a0f3842b-1e4c-4e9d-8f2d-cd021e5a9b6a")

const tasksCollection = user.tasks()
const tasks = await tasksCollection.toArray()
```

### BaseModel static methods

- `attributes()`: Returns attribute metadata for the model. Example: `User.attributes()`
- `hasAttribute(attributeName)`: Checks if the attribute exists on the model. Example: `User.hasAttribute("email")`
- `modelClassData()`: Returns recipe data for the model class. Example: `User.modelClassData()`
- `newCustomEvent(validationErrors)`: Creates a validation-errors event. Example: `BaseModel.newCustomEvent(errors)`
- `sendValidationErrorsEvent(validationErrors, options)`: Dispatches the validation error event to a form. Example: `BaseModel.sendValidationErrorsEvent(errors, {form})`
- `find(id)`: Loads a record by primary key. Example: `User.find("a0f3842b-1e4c-4e9d-8f2d-cd021e5a9b6a")`
- `findOrCreateBy(findOrCreateByArgs, args)`: Find or create a record server-side. Example: `User.findOrCreateBy({email: "sam@example.com"}, {additionalData: {invite: true}})`
- `modelName()`: Returns a `ModelName` helper for inflection. Example: `User.modelName().human()`
- `primaryKey()`: Returns the primary key column name. Example: `User.primaryKey()`
- `ransack(query)`: Starts a collection query. Example: `User.ransack({email_cont: "@example.com"})`
- `select(select)`: Adds a `select` clause to a new query. Example: `User.select({users: ["id", "email"]})`
- `ransackableAssociations()`: Returns associations that are searchable. Example: `User.ransackableAssociations()`
- `ransackableAttributes()`: Returns attributes that are searchable. Example: `User.ransackableAttributes()`
- `ransackableScopes()`: Returns scopes that are searchable. Example: `User.ransackableScopes()`
- `reflections()`: Returns relationship metadata. Example: `User.reflections()`
- `reflection(name)`: Looks up a relationship by name. Example: `User.reflection("account")`
- `_token()`: Reads the CSRF token from the page. Example: `BaseModel._token()`
- `all()`: Alias for `ransack()`. Example: `User.all().toArray()`
- `parseValidationErrors({error, model, options})`: Converts server validation errors to `ValidationErrors`. Example: `BaseModel.parseValidationErrors({error, model: user, options: {form}})`
- `humanAttributeName(attributeName)`: Returns a translated attribute name. Example: `User.humanAttributeName("email")`
- `snakeCase(string)`: Underscores a string. Example: `BaseModel.snakeCase("AccountOwner")`
- `_objectDataFromGivenRawData(rawData, options)`: Normalizes form data or objects. Example: `BaseModel._objectDataFromGivenRawData(form, {})`
- `_callCollectionCommand(args, commandArgs)`: Executes a command and parses validation errors. Example: `BaseModel._callCollectionCommand({args: {save: payload}}, {})`
- `_postDataFromArgs(args)`: Serializes args for a command request. Example: `BaseModel._postDataFromArgs({save: payload})`

### BaseModel instance methods

- `constructor(args)`: Creates a model instance. Example: `const user = new User({id: "123"})`
- `modelClass()`: Returns the model class. Example: `user.modelClass().modelName().name`
- `modelClassData()`: Returns the recipe data for the model. Example: `user.modelClassData().name`
- `primaryKey()`: Reads the primary key value. Example: `user.primaryKey()`
- `identifierKey()`: Returns a stable key for UI lists. Example: `user.identifierKey()`
- `uniqueKey()`: Generates a random unique key. Example: `user.uniqueKey()`
- `cacheKey()`: Returns cache key based on id and updated_at. Example: `user.cacheKey()`
- `localCacheKey()`: Returns a local cache key. Example: `user.localCacheKey()`
- `fullCacheKey()`: Returns a full cache key. Example: `user.fullCacheKey()`
- `clone()`: Returns a shallow clone of the model. Example: `const clone = user.clone()`
- `attributes()`: Returns merged attributes and changes. Example: `user.attributes()`
- `getAttributes()`: Returns attributes merged with changes. Example: `user.getAttributes()`
- `assignAttributes(newAttributes)`: Assigns changes and tracks diffs. Example: `user.assignAttributes({email: "sam@example.com"})`
- `readAttribute(attributeName)`: Reads a camelCase or snake_case attribute. Example: `user.readAttribute("email")`
- `readAttributeUnderscore(attributeName)`: Reads a snake_case attribute. Example: `user.readAttributeUnderscore("email")`
- `isAttributeLoaded(attributeName)`: Checks if an attribute is loaded. Example: `user.isAttributeLoaded("email")`
- `isAttributeChanged(attributeName)`: Checks if an attribute changed. Example: `user.isAttributeChanged("email")`
- `savedChangeToAttribute(attributeName)`: Checks if an attribute changed in the last save. Example: `user.savedChangeToAttribute("email")`
- `isChanged()`: Returns true if any attributes changed. Example: `user.isChanged()`
- `isNewRecord()`: Returns true if the model is not persisted. Example: `user.isNewRecord()`
- `isPersisted()`: Returns true if the model has been saved. Example: `user.isPersisted()`
- `can(abilityName)`: Checks a loaded ability. Example: `user.can("update")`
- `ensureAbilities(listOfAbilities)`: Loads missing abilities from the server. Example: `await user.ensureAbilities(["update"])`
- `isAssociationLoaded(associationName)`: Checks if a relationship is cached. Example: `user.isAssociationLoaded("account")`
- `isAssociationLoadedUnderscore(associationName)`: Checks a relationship cache using snake_case. Example: `user.isAssociationLoadedUnderscore("account")`
- `isAssociationPresent(associationName)`: Checks if the relationship is present in memory. Example: `user.isAssociationPresent("account")`
- `create(attributes, options)`: Creates the record on the server. Example: `await user.create({email: "sam@example.com"})`
- `createRaw(rawData, options)`: Creates the record with raw form data. Example: `await user.createRaw(formElement)`
- `update(newAttributes, options)`: Updates only changed attributes. Example: `await user.update({email: "new@example.com"})`
- `updateRaw(rawData, options)`: Updates with raw form data. Example: `await user.updateRaw(formElement)`
- `save()`: Creates or updates based on `isNewRecord()`. Example: `await user.save()`
- `saveRaw(rawData, options)`: Creates or updates using raw data. Example: `await user.saveRaw(formElement)`
- `destroy()`: Deletes the record. Example: `await user.destroy()`
- `reload()`: Reloads the record with the same query params. Example: `await user.reload()`
- `isValid()`: Not implemented and throws. Example: `user.isValid()`
- `isValidOnServer()`: Runs server-side validation. Example: `await user.isValidOnServer()`
- `handleResponseError(response)`: Raises a structured error from a response. Example: `user.handleResponseError(response)`
- `preloadRelationship(relationshipName, model)`: Marks a relationship as loaded. Example: `user.preloadRelationship("account", account)`
- `markForDestruction()`: Marks a record for later deletion. Example: `user.markForDestruction()`
- `markedForDestruction()`: Returns destruction flag. Example: `user.markedForDestruction()`
- `_callMemberCommand(args, commandArgs)`: Executes a member command. Example: `user._callMemberCommand({args: {}}, {})`
- `_isPresent(value)`: Presence helper used internally. Example: `user._isPresent("hi")`
- `_isDateChanged(oldValue, newValue)`: Date change helper. Example: `user._isDateChanged("2024-01-01", "2024-02-01")`
- `_isIntegerChanged(oldValue, newValue)`: Integer change helper. Example: `user._isIntegerChanged(1, 2)`
- `_isStringChanged(oldValue, newValue)`: String change helper. Example: `user._isStringChanged("a", "b")`
- `setNewModel(model)`: Copies data + relationships from another instance. Example: `user.setNewModel(newUser)`
- `setNewModelData(model)`: Copies only attributes from another instance. Example: `user.setNewModelData(newUser)`
- `_refreshModelFromResponse(response)`: Updates model from a command response. Example: `user._refreshModelFromResponse(response)`
- `_refreshModelDataFromResponse(response)`: Updates attributes from a command response. Example: `user._refreshModelDataFromResponse(response)`
- `_readModelDataFromArgs(args)`: Populates model from API payload. Example: `user._readModelDataFromArgs({data: payload})`
- `_readPreloadedRelationships(preloaded)`: Populates relationship cache from preloads. Example: `user._readPreloadedRelationships(preloaded)`
- `_loadBelongsToReflection(args, queryArgs)`: Loads a belongs_to relationship. Example: `user._loadBelongsToReflection({reflectionName: "account", model: user, modelClass: Account})`
- `_readBelongsToReflection({reflectionName})`: Reads a belongs_to relationship. Example: `user._readBelongsToReflection({reflectionName: "account"})`
- `_loadHasManyReflection(args, queryArgs)`: Loads a has_many relationship. Example: `user._loadHasManyReflection({reflectionName: "tasks", model: user, modelClass: Task})`
- `_loadHasOneReflection(args, queryArgs)`: Loads a has_one relationship. Example: `user._loadHasOneReflection({reflectionName: "profile", model: user, modelClass: Profile})`
- `_readHasOneReflection({reflectionName})`: Reads a has_one relationship. Example: `user._readHasOneReflection({reflectionName: "profile"})`

### Collection methods

- `constructor(args, queryArgs)`: Creates a collection query. Example: `new Collection({modelClass: User})`
- `modelClass()`: Returns the model class for the collection. Example: `collection.modelClass()`
- `clone()`: Returns a copy of the collection. Example: `const next = collection.clone()`
- `abilities(abilities)`: Adds ability filters to the query. Example: `User.ransack().abilities({User: ["update"]})`
- `accessibleBy(abilityName)`: Restricts by ability. Example: `User.ransack().accessibleBy("read")`
- `ransack(params)`: Adds ransack params. Example: `User.ransack().ransack({email_cont: "@example.com"})`
- `search(params)`: Adds search params. Example: `User.ransack().search({q: "sam"})`
- `searchKey(searchKey)`: Sets the search key. Example: `User.ransack().searchKey("query")`
- `select(select)`: Selects attributes per model. Example: `User.ransack().select({users: ["id", "email"]})`
- `selectColumns(selectColumns)`: Selects columns per table. Example: `User.ransack().selectColumns({users: ["id", "email"]})`
- `preload(preloadValue)`: Preloads relationships. Example: `User.ransack().preload(["account"])`
- `groupBy(...columns)`: Adds group-by columns. Example: `User.ransack().groupBy("users.id")`
- `sort(sortBy)`: Adds sort to ransack params. Example: `User.ransack().sort("created_at desc")`
- `distinct()`: Enables distinct results. Example: `User.ransack().distinct()`
- `limit(amount)`: Limits the result count. Example: `User.ransack().limit(10)`
- `page(page)`: Sets the page number. Example: `User.ransack().page(2)`
- `per(per)`: Sets the page size. Example: `User.ransack().per(50)`
- `pageKey(pageKey)`: Sets a custom page param key. Example: `User.ransack().pageKey("p")`
- `perKey(perKey)`: Sets a custom per param key. Example: `User.ransack().perKey("per_page")`
- `except(...keys)`: Removes query keys. Example: `User.ransack().page(2).except("page")`
- `params()`: Returns request params. Example: `User.ransack().limit(5).params()`
- `isFiltered()`: Returns true when filters are set. Example: `User.ransack().limit(5).isFiltered()`
- `count()`: Returns count for the query. Example: `await User.ransack().count()`
- `first()`: Returns the first record. Example: `await User.ransack().first()`
- `toArray()`: Executes and returns models. Example: `await User.ransack().toArray()`
- `result()`: Executes and returns a `Result` wrapper. Example: `const result = await User.ransack().result()`
- `each(callback)`: Iterates over the loaded array. Example: `await User.ransack().each((user) => console.log(user.id()))`
- `ensureLoaded()`: Loads and sets relationship models. Example: `await user.tasks().ensureLoaded()`
- `isLoaded()`: Checks if a relationship is loaded. Example: `user.tasks().isLoaded()`
- `preloaded()`: Returns cached models for a relationship. Example: `user.tasks().preloaded()`
- `loaded()`: Returns the loaded relationship value. Example: `user.tasks().loaded()`
- `loadedArray()`: Returns loaded relationship array. Example: `user.tasks().loadedArray()`
- `set(newCollection)`: Replaces the relationship collection. Example: `user.tasks().set([task])`
- `push(newModel)`: Pushes a model onto the relationship collection. Example: `user.tasks().push(task)`
- `find(callback)`: Finds within the loaded array. Example: `user.tasks().find((task) => task.id() == "1")`
- `forEach(callback)`: Iterates the loaded array. Example: `user.tasks().forEach((task) => console.log(task.id()))`
- `map(callback)`: Maps the loaded array. Example: `user.tasks().map((task) => task.title())`
- `_addQueryToModels(models)`: Assigns the collection to returned models. Example: `collection._addQueryToModels(models)`
- `_merge(newQueryArgs)`: Merges query args. Example: `collection._merge({limit: 5})`
- `_response()`: Executes the request and returns the raw response. Example: `await collection._response()`

## Related docs

The server-side setup, resource definitions, and serializers live in the main ApiMaker repository README.