Lab / rest-domain-state-manager

rest-domain-state-manager

A pure ES Module library that wraps REST API response data in a Proxy for automatic change tracking, then dispatches the optimal HTTP method (POST / PUT / PATCH) at save time.

No bundler  ·  No npm  ·  No dependencies

Quick Navigator

⚡ Part 1 — Getting Started

1-1. Installation

No bundler, npm, or CDN required. Copy the folder and import.

your-project/
├── rest-domain-state-manager/
└── index.html
<script type="module">
  import { ApiHandler, DomainState }
    from './rest-domain-state-manager/rest-domain-state-manager.js';
</script>

1-2. Creating an ApiHandler

ApiHandler handles HTTP transport. You create the instance — this is where you set the server address.

const api = new ApiHandler({
  host:  'localhost:8080',
  debug: true,
});
// Multiple backends: create one instance per server
const userApi  = new ApiHandler({ host: 'user-service.com' });
const orderApi = new ApiHandler({ host: 'order-service.com' });

1-3. GET → Modify → Save

const user = await api.get('/api/users/user_001');
console.log(user.data.name);           // read
user.data.name         = 'Davi';       // tracked → op: replace
user.data.address.city = 'Seoul';      // nested → tracked
await user.save('/api/users/user_001'); // PATCH (changes) or PUT (no changes)
await user.remove('/api/users/user_001');

1-4. Creating New Data → POST

fromVO()

class UserVO extends DomainVO {
  static baseURL = 'localhost:8080/api/users';
  static fields  = {
    user_id: { default: '' },
    name:    { default: '' },
    role:    { default: 'USER' },
    address: { default: { city: '', zip: '' } },
  };
}
const newUser = DomainState.fromVO(new UserVO(), api);
newUser.data.user_id = 'user_' + Date.now();
newUser.data.name    = 'Davi';
await newUser.save(); // POST to static baseURL

fromForm()

<form id="user-form">
  <input name="name" />
  <input name="address.city" />
  <select name="role">...</select>
</form>
const formState = DomainState.fromForm('user-form', api);
await formState.save('/api/users');

1-5. Multiple APIs at Once (DomainPipeline)

const result = await DomainState.all({
  roles: api.get('/api/roles'),
  user:  api.get('/api/users/user_001'),
})
.after('roles', async (roles) => { console.log(roles.data); })
.after('user',  async (user)  => { console.log(user.data);  })
.run();
if (result._errors?.length) console.warn(result._errors);

1-6. Rendering Lists as UI Elements (DomainRenderer)

DomainState.use(DomainRenderer); // register once
const roles = await api.get('/api/roles');
roles.renderTo('#role-select', {
  type: 'select', valueField: 'roleId', labelField: 'roleName',
  class: 'form-select', placeholder: 'Select a role',
  events: { change: (e) => console.log(e.target.value) },
});
roles.renderTo('#role-radio', {
  type: 'radio', valueField: 'roleId', labelField: 'roleName',
  containerClass: 'form-check', class: 'form-check-input', labelClass: 'form-check-label',
});
🔬 Part 2 — Deep Dive

2-1. ApiHandler Full Options

OptionTypeDescription
hoststringHost without protocol (e.g. api.example.com)
basePathstringCommon path prefix (e.g. /app/api)
baseURLstringCombined host + basePath string
protocolstring'HTTP'|'HTTPS'|'FILE'|'SSH'
envstring'development'|'production'
debugbooleanWhen true, treated as development environment
⚠️
host and baseURL are mutually exclusive. Conflict auto-resolution is attempted; if impossible an Error is thrown.
// Style 1 — decomposed
const api = new ApiHandler({ host: 'api.example.com', basePath: '/app/api', env: 'production' });
// Style 2 — unified
const api = new ApiHandler({ baseURL: 'localhost:8080/app/api', debug: true });
// Per-request override
const user = await api.get('/api/users/1', { urlConfig: { host: 'other-server.com' } });

Protocol resolution priority

1. Explicit protocol option
2. env → development: HTTP, production: HTTPS
3. No env + debug: true  → HTTP
4. No env + debug: false → HTTPS

2-2. DomainVO Field Declaration

class ProductVO extends DomainVO {
  static baseURL = 'localhost:8080/api/products';
  static fields = {
    productId: { default: '' },
    name:      { default: '', validate: (v) => v.trim().length > 0 },
    price:     { default: 0,  validate: (v) => v >= 0, transform: Number },
    tags:      { default: [] },
    meta:      { default: { createdAt: '', updatedAt: '' } },
  };
}
const product = DomainState.fromVO(new ProductVO(), api);
await product.save(); // POST to static baseURL
// Schema validation on GET response:
const p2 = DomainState.fromJSON(jsonText, api, { vo: new ProductVO() });

2-3. DomainState Internals

const user = await api.get('/api/users/1');
user.data.name         = 'Davi';   // changeLog: [{ op:'replace', path:'/name', ... }]
user.data.address.city = 'Seoul';  // deep proxy → path '/address/city'
delete user.data.phone;             // op: 'remove'
console.log(user._getChangeLog());
FactoryisNewsave() result
api.get() / fromJSON()falsePATCH if changes, PUT if none
fromVO()truePOST
fromForm()truePOST

2-4. Factory Methods in Detail

fromJSON options

OptionTypeDescription
urlConfigUrlConfigURL config override
debugbooleanDebug mode
labelstringDebug popup display name
voDomainVOSchema validation + inject validators/transformers
formstring|HTMLFormElementAuto-populate form with response data

fromForm event tracking

ElementEventReason
input[type=text], textareablurAvoids spurious set traps while typing
select, radio, checkboxchangeValue finalized immediately on selection

2-5. save() Branching Strategy

isNew === true                    → POST  (toPayload)
isNew === false
    changeLog.length > 0          → PATCH (RFC 6902 JSON Patch)
    changeLog.length === 0        → PUT   (toPayload)
await user.save('/api/users/1');  // explicit path
await user.save();                // uses urlConfig or static baseURL
After successful requestAction
PATCH / PUTchangeLog cleared
POSTisNew → false + changeLog cleared

2-6. DomainPipeline strict Mode / _errors Handling

strictBehaviorReturns
false (default)On failure: record to _errors, continueresolve + _errors
trueImmediately reject on first failurereject
// strict: false
const result = await DomainState.all({ roles: api.get('/api/roles'), user: api.get('/api/users/BAD') })
  .after('roles', async r => { /* ... */ })
  .run();
result._errors?.forEach(({ key, error }) => console.warn(key, error));

// strict: true
try {
  await DomainState.all({ ... }, { strict: true }).after('roles', r => {}).run();
} catch (err) { console.error('Pipeline aborted:', err); }

2-7. renderTo() Full Config

Common (all types)

OptionTypeDescription
typestring'select'|'radio'|'checkbox'|'button'
valueFieldstringField name used as each item's value
labelFieldstringField name used as display text
classstringElement className
cssobjectInline style (camelCase keys)
eventsobject{ eventName: handler }

select extras

OptionDescription
placeholderFirst disabled option text
multipleEnable multi-select

radio / checkbox extras

OptionDescription
nameinput[name]. Defaults to valueField (MyBatis auto-match).
containerClass / containerCssWrapper div className / inline style
labelClass / labelCssLabel element className / inline style

button

valueField value is injected as button[data-value]. Read from e.target.dataset.value.

2-8. Plugin System (use())

const MyPlugin = {
  install(DomainState) {
    DomainState.prototype.toCSV = function () {
      return Object.values(this._getTarget()).join(',');
    };
  },
};
DomainState.use(MyPlugin).use(AnotherPlugin); // chainable
const user = await api.get('/api/users/1');
console.log(user.toCSV());
ℹ️
Duplicate registration is silently ignored. install() runs at most once per plugin object. If the plugin has no install method, a TypeError is thrown.

2-9. Debug Popup

const user = DomainState.fromVO(new UserVO(), api, { debug: true, label: 'UserVO' });
user.openDebugger();

The popup shows all tabs on the same origin in real time: DomainState label, current data, changeLog entries, and _errors array.

⚠️
Allow browser popup blocking for this to work. DomainState created via fromForm() auto-refreshes the popup on form events.
🏗 Architecture

Layer Overview

┌──────────────────────────────────────────────────────────────────────┐
│                           Entry Point                                │
│                  rest-domain-state-manager.js                        │
│        { ApiHandler, DomainState, DomainVO,                         │
│          DomainPipeline, DomainRenderer }                            │
└───────────────┬──────────────────────────────┬───────────────────────┘
                │                              │
┌───────────────▼──────────────┐  ┌────────────▼─────────────────────┐
│           model/             │  │           plugin/                │
│  DomainState                 │  │  DomainRenderer                  │
│  DomainVO                    │  │    renderers/                    │
│  DomainPipeline              │  └──────────────────────────────────┘
└───────────────┬──────────────┘
                │ internal calls
┌───────────────▼──────────────────────────────────────────────────────┐
│                           src/                                       │
│  handler/   core/          constants/        debug/                 │
│  ApiHandler api-proxy.js   error.messages    debug-channel.js       │
│             api-mapper.js  op.const          (BroadcastChannel      │
│             url-resolver   protocol.const     + popup HTML)         │
└──────────────────────────────────────────────────────────────────────┘

Drawbridge Pattern

createProxy() returns four closures that DomainState stores internally. This is called the Drawbridge Pattern — the library controls all four gates into the closure world, and only exposes the proxy gate to the outside.

// What createProxy() returns
{
  proxy:          object,     // The only public-facing gate
  getChangeLog:   () => [],   // Internal gate: read change history
  getTarget:      () => {},   // Internal gate: read the raw object
  clearChangeLog: () => void, // Internal gate: reset after sync
}
💡
Consumers only ever touch domainState.data (the proxy). The other three closures are called exclusively by DomainState internals. This boundary guarantees changeLog integrity.

Proxy Trap Design

Path accumulation

proxy.address.city = 'Seoul'
// get trap:  prop='address', basePath=''  → returns new Proxy(basePath='/address')
// set trap:  prop='city',    basePath='/address' → records path '/address/city'
TrapTriggeropNote
getProperty readReturns deep proxy if value is Object/Array
set (new key)Assign to absent keyaddAlso reflects on target
set (replace)Change existing valuereplaceRecords oldValue
deletePropertydelete proxy.fieldremoveRecords oldValue

Bypass list

These are returned via Reflect.get without deep proxy wrapping: Symbol properties, toJSON, then, valueOf.

Data Flow

GET → Display

Server (JSON response)
    ↓ fetch()
api-handler._fetch()       — common headers, response.ok check
    ↓ text (JSON string)
api-mapper.toDomain()      — JSON.parse → plain object
    ↓
api-proxy.createProxy()    — wrap in Proxy, create drawbridge set
    ↓
DomainState constructor    — store metadata, broadcast to debug channel
    ↓
consumer code              — domainState.data.xxx read/write

save()

domainState.save(path)
    ↓ isNew check
    ├─ true  → toPayload() → POST
    └─ false → changeLog check
                  ├─ length > 0 → toPatch()   → PATCH (RFC 6902)
                  └─ length = 0 → toPayload() → PUT
    ↓ on success
clearChangeLog()   // isNew = false if POST

File Structure

rest-domain-state-manager/
├── rest-domain-state-manager.js     Single entry point
├── proxy.test.html                  Integration tests (7 cases)
├── model/
│   ├── DomainState.js
│   ├── DomainVO.js
│   └── DomainPipeline.js
├── src/
│   ├── constants/
│   │   ├── protocol.const.js
│   │   ├── op.const.js
│   │   ├── channel.const.js
│   │   ├── error.messages.js
│   │   └── log.messages.js
│   ├── common/js-object-util.js
│   ├── core/
│   │   ├── api-proxy.js
│   │   ├── url-resolver.js
│   │   └── api-mapper.js
│   ├── handler/api-handler.js
│   └── debug/debug-channel.js
└── plugin/
    └── domain-renderer/
        ├── DomainRenderer.js
        ├── renderer.const.js
        └── renderers/
            ├── select.renderer.js
            ├── radio-checkbox.renderer.js
            └── button.renderer.js
📋 API Reference

ApiHandler

MethodSignatureReturnsDescription
get()get(path, opts?)Promise<DomainState>GET → DomainState (isNew: false)
_fetch()_fetch(url, opts?)Promise<string|null>Internal. Called by save/remove.
getUrlConfig()objectReturns normalized URL config
isDebug()booleanReturns debug flag

Error structure

{ status: number, statusText: string, body: string }
// thrown when response.ok is false

DomainState

Static methods

MethodSignatureReturns
fromJSON()fromJSON(text, handler, opts?)DomainState (isNew: false)
fromForm()fromForm(form, handler, opts?)DomainState (isNew: true)
fromVO()fromVO(vo, handler, opts?)DomainState (isNew: true)
all()all(resourceMap, opts?)DomainPipeline
use()use(plugin)DomainState (chainable)

Instance

MemberTypeDescription
.dataProxy (getter)The only public entry point for domain data
.save(path?)Promise<void>POST / PATCH / PUT auto-dispatch
.remove(path?)Promise<void>DELETE
.log()voidLog changeLog to console (debug: true only)
.openDebugger()voidOpen debug popup (debug: true only)
._getChangeLog()arrayInternal. Shallow copy of change history.
._getTarget()objectInternal. Raw underlying object.

DomainVO

MethodReturnsDescription
toSkeleton()objectBuild initial object from fields.default
getValidators()object{ field: fn } map
getTransformers()object{ field: fn } map
getBaseURL()string|nullReturns static baseURL
checkSchema(data){ valid, missingKeys, extraKeys }Compare response with schema

DomainPipeline

MethodSignatureReturns
after()after(key, handler)DomainPipeline (chainable)
run()run()Promise<{ [key]: DomainState, _errors? }>

DomainRenderer

After DomainState.use(DomainRenderer), renderTo(selector, config) is available on all DomainState instances. Requires the instance to hold array data.

💡 Core Concepts

Library Identity

This library is not an HTTP client. It uses fetch() internally, but its core value is not "how to send requests" — it is "tracking what changed and synchronizing correctly at save time".

HTTP clientrest-domain-state-manager
FocusRequest/response transportDomain state change tracking + sync
Usagefetch('/api', { method, body })data.name = '...' → auto-tracked
HTTP methodDeveloper decidesLibrary decides automatically
Change scopeFull payloadChanged paths only (RFC 6902 Patch)

Single Source of Truth

While a screen is active, one DomainState instance holds the single truth for that resource. All changes — DOM elements, form input, direct assignment — flow exclusively through domainState.data (the Proxy).

// Anti-pattern — reading DOM directly
const payload = { name: document.getElementById('name').value };

// Correct — Proxy is the single source
const user = DomainState.fromForm('userForm', api);
await user.save('/api/users/1');

changeLog Structure

The changeLog is an ordered record of every mutation since the last sync. Each entry follows an internal format compatible with RFC 6902 JSON Patch.

// Internal changeLog entry
{ op: 'add'|'replace'|'remove', path: '/address/city', value: 'Seoul', oldValue: 'Busan' }

// Converted to RFC 6902 when PATCH is dispatched
[{ "op": "replace", "path": "/address/city", "value": "Seoul" }]

isNew Flag

isNew indicates whether this instance represents a resource not yet persisted to the server. It is set at factory call time and flipped to false automatically after a successful POST.

const newUser = DomainState.fromVO(new UserVO(), api); // isNew: true
await newUser.save();   // → POST
// After success: isNew is now false
newUser.data.name = 'Davi';
await newUser.save();   // → PATCH (now treated as existing resource)

Locality of Behavior

This library treats Locality of Behavior as a core design principle. Related behavior should live together so reading the code reveals the full picture. One well-commented 50-line function is preferred over ten 5-line functions.

// Design intent:
// "What happens after save() should be predictable
//  without reading the source."
await user.save('/api/users/1');
// isNew false + changes → PATCH
// isNew false + no changes → PUT
// isNew true → POST
// One interface. Three predictable behaviors.
📝 Design Decisions

Each decision was made after evaluating alternatives and trade-offs. This is the record of "why it was designed this way."

DD-1. Proxy Scope

OptionProsCons
A. Service/domain layer onlyNo conflict with React immutability rules. Clear debug boundary.View layer cannot enjoy "assignment = sync."
B. All the way to view/state layermodel.name = '...' triggers sync directly.Conflicts with React shallow comparison. Array type-check bugs. Harder to debug.
Adopted: A — Service/domain layer only for the first implementation. React integration is deferred to a separate experimental branch.

DD-2. HTTP Method Strategy

OptionProsCons
PUT onlySimple. No change tracking needed.Misses the core goal of path-level tracking. Wasteful for large objects.
PATCH onlySend changed fields only.Sends empty array when nothing changed. POST case unresolved.
POST / PATCH / PUT mixedSemantically correct per scenario. PUT ensures idempotency when nothing changed.Requires managing both isNew and changeLog.
Adopted: Mixed (A+C) — isNew flag + changeLog length drive automatic three-way dispatch.

DD-3. deep proxy Scope

Adopted: Object + 1D Array, incremental implementation — Design includes Array; implementation order is Object → Array index access → nested structures. Array mutating methods (push, splice) precision improvement is a future task.

DD-4. changeLog Format

Adopted: Internal format + conversion layer — Internal entries keep oldValue and extra metadata. api-mapper.toPatch() converts to RFC 6902 at send time.

DD-5. Form Binding

Adopted: Path-based bindinginput[name] dot notation encodes nested paths. fromForm() alone handles initialization + binding + tracking as a complete unit.

DD-6. strict Default

Adopted: strict: false — HTTP requests are already spent cost. Post-processing handler failures are independent concerns; record them in _errors and continue. Use strict: true only when hard stopping is explicitly needed.

DD-7. Debug Channel

Adopted: BroadcastChannel — Browser-native API, no dependencies. TAB_PING is broadcast by the popup itself (not the tab) to work around the self-message restriction. Popup HTML is generated as a string via _buildPopupHTML() — no separate HTML file needed.

DD-8. radio / checkbox name Default

Adopted: valueField as name default — Matches MyBatis ResultMap field names automatically for SI project JSP form submit patterns. Override with explicit name option only when a different name is needed.