@routevn/creator-model 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuusoft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # RouteVN Creator Model
2
+
3
+ Shared RouteVN domain model package.
4
+
5
+ Repo rules and contribution expectations are in
6
+ [GUIDELINES.md](./GUIDELINES.md).
7
+
8
+ This repo is intended to be the single source of truth for:
9
+
10
+ - state validation
11
+ - command payload validation
12
+ - state-aware command preconditions
13
+ - command-to-state reduction
14
+
15
+ It is intentionally **not** responsible for:
16
+
17
+ - Insieme transport
18
+ - Insieme storage
19
+ - partition routing
20
+ - actors, tokens, client timestamps
21
+
22
+ Those stay in the client and server repos.
23
+
24
+ ## Public API
25
+
26
+ ```js
27
+ SCHEMA_VERSION;
28
+
29
+ validateState({ state });
30
+
31
+ validatePayload({ type, payload });
32
+
33
+ validateAgainstState({
34
+ state,
35
+ command: { type, payload },
36
+ });
37
+
38
+ processCommand({
39
+ state,
40
+ command: { type, payload },
41
+ });
42
+ ```
43
+
44
+ `SCHEMA_VERSION` is the exported schema version constant for persisted command
45
+ compatibility.
46
+
47
+ Validation functions return:
48
+
49
+ ```js
50
+ {
51
+ valid: true;
52
+ }
53
+ ```
54
+
55
+ or:
56
+
57
+ ```js
58
+ {
59
+ valid: false,
60
+ error: {
61
+ kind: "state" | "payload" | "precondition" | "invariant",
62
+ code: "payload_validation_failed",
63
+ message: "payload.data.foo is not allowed",
64
+ path: "payload.data.foo", // only when available
65
+ details: {}, // only when available
66
+ },
67
+ }
68
+ ```
69
+
70
+ `processCommand()` returns:
71
+
72
+ ```js
73
+ { valid: true, state: nextState }
74
+ ```
75
+
76
+ Design rules:
77
+
78
+ - no classes
79
+ - pure functions whenever possible
80
+ - command payload shape is validated separately from state-aware preconditions
81
+ - `SCHEMA_VERSION` is the source of truth for persisted command schema versioning
82
+ - `processCommand()` is the authoritative state transition
83
+ - model state should contain project-owned runtime data only
84
+ - app-owned metadata like project id, name, and description should stay out of
85
+ this package
86
+ - `project` may start empty; fields like `resolution` are optional until the
87
+ model starts owning them
88
+
89
+ ## File Structure
90
+
91
+ ```text
92
+ src/
93
+ index.js
94
+ errors.js
95
+ helpers.js
96
+ model.js
97
+ tests/
98
+ model-api.test.js
99
+ command-direct-coverage.test.js
100
+ project.create.spec.yaml
101
+ story-and-scenes.spec.yaml
102
+ scenes-advanced.spec.yaml
103
+ sections-and-lines.spec.yaml
104
+ images.spec.yaml
105
+ sounds-and-videos.spec.yaml
106
+ animations.spec.yaml
107
+ fonts-and-colors.spec.yaml
108
+ transforms-variables-textstyles.spec.yaml
109
+ characters-and-layouts.spec.yaml
110
+ state-validation.spec.yaml
111
+ animations-drift.test.js
112
+ command-sequences.test.js
113
+ ```
114
+
115
+ ## How This Maps To The Current Client Repo
116
+
117
+ Current RouteVN files:
118
+
119
+ - `src/internal/project/commands.js`
120
+ - `src/internal/project/state.js`
121
+
122
+ should map into this package like this:
123
+
124
+ - `src/errors.js`
125
+ - internal domain error factories
126
+ - `src/helpers.js`
127
+ - tiny pure shared helpers
128
+ - `src/model.js`
129
+ - state validation
130
+ - invariants
131
+ - command definitions
132
+ - payload validation
133
+ - state-aware validation
134
+ - reduction
135
+ - `src/index.js`
136
+ - public exports only
137
+
138
+ `projection.js` should stay in the app repos for now. It is downstream of the
139
+ domain model and is still tied to current app/repository needs.
140
+
141
+ ## Intended Usage
142
+
143
+ Client:
144
+
145
+ 1. validate payload before submit when useful
146
+ 2. optionally run `processCommand()` for optimistic apply
147
+ 3. send to Insieme transport
148
+
149
+ Server:
150
+
151
+ 1. validate payload at submit boundary
152
+ 2. validate against current state before commit
153
+ 3. commit event to storage
154
+ 4. use `processCommand()` for authoritative projection
155
+
156
+ ## Testing
157
+
158
+ This repo uses Bun + Vitest + Puty.
159
+
160
+ - runner: `bunx vitest run`
161
+ - package script: `bun run test`
162
+ - benchmark script: `bun run bench`
163
+ - YAML specs live in `tests/**/*.spec.yaml`
164
+ - JS sequence tests live in `tests/**/*.test.js`
165
+
166
+ There are 2 test styles:
167
+
168
+ 1. Command contract specs
169
+ - treat commands as pure functions
170
+ - validate one call at a time
171
+ - assert exact input/output or expected invalid result
172
+ - include a direct command coverage matrix for the full public registry
173
+ - examples:
174
+ - [tests/command-direct-coverage.test.js](./tests/command-direct-coverage.test.js)
175
+ - [tests/project.create.spec.yaml](./tests/project.create.spec.yaml)
176
+ - [tests/story-and-scenes.spec.yaml](./tests/story-and-scenes.spec.yaml)
177
+ - [tests/state-validation.spec.yaml](./tests/state-validation.spec.yaml)
178
+
179
+ 2. Command sequence tests
180
+ - apply a sequence of commands
181
+ - assert the full state after each step
182
+ - also assert the previous state was not mutated
183
+ - use these for reducer flows that are easier to reason about as a tape
184
+ - example:
185
+ - [tests/command-sequences.test.js](./tests/command-sequences.test.js)
186
+
187
+ YAML Puty specs use [tests/support/putyApi.js](./tests/support/putyApi.js) as a
188
+ small adapter so the declarative `throws:` assertions can stay concise while the
189
+ real public API returns `{ valid: ... }` result objects.
190
+
191
+ ## Current Scope
192
+
193
+ Currently implemented command types:
194
+
195
+ - `project.create`
196
+ - `story.update`
197
+ - `scene.create`
198
+ - `scene.update`
199
+ - `scene.delete`
200
+ - `scene.move`
201
+ - `section.create`
202
+ - `section.update`
203
+ - `section.delete`
204
+ - `section.move`
205
+ - `line.create`
206
+ - `line.update_actions`
207
+ - `line.delete`
208
+ - `line.move`
209
+ - `image.create`
210
+ - `image.update`
211
+ - `image.delete`
212
+ - `image.move`
213
+ - `sound.create`
214
+ - `sound.update`
215
+ - `sound.delete`
216
+ - `sound.move`
217
+ - `video.create`
218
+ - `video.update`
219
+ - `video.delete`
220
+ - `video.move`
221
+ - `animation.create`
222
+ - `animation.update`
223
+ - `animation.delete`
224
+ - `animation.move`
225
+ - `font.create`
226
+ - `font.update`
227
+ - `font.delete`
228
+ - `font.move`
229
+ - `color.create`
230
+ - `color.update`
231
+ - `color.delete`
232
+ - `color.move`
233
+ - `transform.create`
234
+ - `transform.update`
235
+ - `transform.delete`
236
+ - `transform.move`
237
+ - `variable.create`
238
+ - `variable.update`
239
+ - `variable.delete`
240
+ - `variable.move`
241
+ - `textStyle.create`
242
+ - `textStyle.update`
243
+ - `textStyle.delete`
244
+ - `textStyle.move`
245
+ - `character.create`
246
+ - `character.update`
247
+ - `character.delete`
248
+ - `character.move`
249
+ - `layout.create`
250
+ - `layout.update`
251
+ - `layout.delete`
252
+ - `layout.move`
253
+ - `character.sprite.create`
254
+ - `character.sprite.update`
255
+ - `character.sprite.delete`
256
+ - `character.sprite.move`
257
+ - `layout.element.create`
258
+ - `layout.element.update`
259
+ - `layout.element.delete`
260
+ - `layout.element.move`
261
+
262
+ The rest of the future command surface should be added only when full
263
+ validation, preconditions, reducer behavior, and tests are added together.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@routevn/creator-model",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "description": "Shared RouteVN domain model, validators, and reducer",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/RouteVN/routevn-creator-model.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/RouteVN/routevn-creator-model/issues"
14
+ },
15
+ "homepage": "https://github.com/RouteVN/routevn-creator-model#readme",
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "sideEffects": false,
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "exports": {
26
+ ".": "./src/index.js"
27
+ },
28
+ "scripts": {
29
+ "test": "bunx vitest run",
30
+ "test:watch": "bunx vitest",
31
+ "bench": "bun scripts/benchmark-process-command.js"
32
+ },
33
+ "devDependencies": {
34
+ "puty": "0.1.2",
35
+ "vitest": "^3.2.1"
36
+ }
37
+ }
package/src/errors.js ADDED
@@ -0,0 +1,39 @@
1
+ const createDomainError = ({ name, code, message, details = {} }) => {
2
+ const error = new Error(message);
3
+ error.name = name;
4
+ error.code = code;
5
+ error.details = details;
6
+ return error;
7
+ };
8
+
9
+ export const createPayloadValidationError = (message, details) =>
10
+ createDomainError({
11
+ name: "PayloadValidationError",
12
+ code: "payload_validation_failed",
13
+ message,
14
+ details,
15
+ });
16
+
17
+ export const createPreconditionValidationError = (message, details) =>
18
+ createDomainError({
19
+ name: "PreconditionValidationError",
20
+ code: "precondition_validation_failed",
21
+ message,
22
+ details,
23
+ });
24
+
25
+ export const createStateValidationError = (message, details) =>
26
+ createDomainError({
27
+ name: "StateValidationError",
28
+ code: "state_validation_failed",
29
+ message,
30
+ details,
31
+ });
32
+
33
+ export const createInvariantValidationError = (message, details) =>
34
+ createDomainError({
35
+ name: "InvariantValidationError",
36
+ code: "invariant_validation_failed",
37
+ message,
38
+ details,
39
+ });
package/src/helpers.js ADDED
@@ -0,0 +1,235 @@
1
+ export const isPlainObject = (value) =>
2
+ value !== null && typeof value === "object" && !Array.isArray(value);
3
+
4
+ export const isNonEmptyString = (value) =>
5
+ typeof value === "string" && value.trim().length > 0;
6
+
7
+ export const isFiniteNumber = (value) =>
8
+ typeof value === "number" && Number.isFinite(value);
9
+
10
+ export const findTreeNode = ({ nodes, nodeId }) => {
11
+ if (!Array.isArray(nodes)) {
12
+ return undefined;
13
+ }
14
+
15
+ for (const node of nodes) {
16
+ if (!node || typeof node !== "object") {
17
+ continue;
18
+ }
19
+
20
+ if (node.id === nodeId) {
21
+ return node;
22
+ }
23
+
24
+ const nestedNode = findTreeNode({
25
+ nodes: node.children,
26
+ nodeId,
27
+ });
28
+
29
+ if (nestedNode) {
30
+ return nestedNode;
31
+ }
32
+ }
33
+
34
+ return undefined;
35
+ };
36
+
37
+ export const findTreeParentId = ({ nodes, nodeId, parentId = null }) => {
38
+ if (!Array.isArray(nodes)) {
39
+ return undefined;
40
+ }
41
+
42
+ for (const node of nodes) {
43
+ if (!node || typeof node !== "object") {
44
+ continue;
45
+ }
46
+
47
+ if (node.id === nodeId) {
48
+ return parentId;
49
+ }
50
+
51
+ const nestedParentId = findTreeParentId({
52
+ nodes: node.children,
53
+ nodeId,
54
+ parentId: node.id,
55
+ });
56
+
57
+ if (nestedParentId !== undefined) {
58
+ return nestedParentId;
59
+ }
60
+ }
61
+
62
+ return undefined;
63
+ };
64
+
65
+ const getSiblingNodes = ({ tree, parentId }) => {
66
+ if (parentId === null || parentId === undefined) {
67
+ return tree;
68
+ }
69
+
70
+ const parentNode = findTreeNode({
71
+ nodes: tree,
72
+ nodeId: parentId,
73
+ });
74
+
75
+ if (!Array.isArray(parentNode.children)) {
76
+ parentNode.children = [];
77
+ }
78
+
79
+ return parentNode.children;
80
+ };
81
+
82
+ const resolveInsertIndex = ({ siblings, index, position, positionTargetId }) => {
83
+ if (Number.isInteger(index)) {
84
+ return Math.max(0, Math.min(index, siblings.length));
85
+ }
86
+
87
+ if (position === "first") {
88
+ return 0;
89
+ }
90
+
91
+ if (position === "before" && isNonEmptyString(positionTargetId)) {
92
+ return Math.max(0, siblings.findIndex((entry) => entry.id === positionTargetId));
93
+ }
94
+
95
+ if (position === "after" && isNonEmptyString(positionTargetId)) {
96
+ const targetIndex = siblings.findIndex((entry) => entry.id === positionTargetId);
97
+ return targetIndex >= 0 ? targetIndex + 1 : siblings.length;
98
+ }
99
+
100
+ return siblings.length;
101
+ };
102
+
103
+ export const insertTreeNode = ({
104
+ tree,
105
+ node,
106
+ parentId = null,
107
+ index,
108
+ position,
109
+ positionTargetId,
110
+ }) => {
111
+ const siblings = getSiblingNodes({
112
+ tree,
113
+ parentId,
114
+ });
115
+
116
+ const insertIndex = resolveInsertIndex({
117
+ siblings,
118
+ index,
119
+ position,
120
+ positionTargetId,
121
+ });
122
+
123
+ siblings.splice(insertIndex, 0, node);
124
+ };
125
+
126
+ export const insertScopedTreeNode = ({
127
+ tree,
128
+ node,
129
+ parentId = null,
130
+ index,
131
+ position,
132
+ positionTargetId,
133
+ isSibling,
134
+ }) => {
135
+ const siblings = getSiblingNodes({
136
+ tree,
137
+ parentId,
138
+ });
139
+
140
+ const matchingIndexes = siblings
141
+ .map((entry, entryIndex) => (isSibling(entry) ? entryIndex : -1))
142
+ .filter((entryIndex) => entryIndex >= 0);
143
+
144
+ let insertIndex = siblings.length;
145
+
146
+ if (Number.isInteger(index)) {
147
+ if (matchingIndexes.length === 0) {
148
+ insertIndex = Math.max(0, Math.min(index, siblings.length));
149
+ } else if (index <= 0) {
150
+ insertIndex = matchingIndexes[0];
151
+ } else if (index >= matchingIndexes.length) {
152
+ insertIndex = matchingIndexes[matchingIndexes.length - 1] + 1;
153
+ } else {
154
+ insertIndex = matchingIndexes[index];
155
+ }
156
+ } else if (position === "first") {
157
+ insertIndex = matchingIndexes[0] ?? siblings.length;
158
+ } else if (position === "before" && isNonEmptyString(positionTargetId)) {
159
+ const targetIndex = siblings.findIndex((entry) => entry.id === positionTargetId);
160
+ insertIndex = targetIndex >= 0 ? targetIndex : siblings.length;
161
+ } else if (position === "after" && isNonEmptyString(positionTargetId)) {
162
+ const targetIndex = siblings.findIndex((entry) => entry.id === positionTargetId);
163
+ insertIndex = targetIndex >= 0 ? targetIndex + 1 : siblings.length;
164
+ } else if (matchingIndexes.length > 0) {
165
+ insertIndex = matchingIndexes[matchingIndexes.length - 1] + 1;
166
+ }
167
+
168
+ siblings.splice(insertIndex, 0, node);
169
+ };
170
+
171
+ export const removeTreeNode = ({ nodes, nodeId }) => {
172
+ if (!Array.isArray(nodes)) {
173
+ return undefined;
174
+ }
175
+
176
+ for (let index = 0; index < nodes.length; index += 1) {
177
+ const node = nodes[index];
178
+ if (!node || typeof node !== "object") {
179
+ continue;
180
+ }
181
+
182
+ if (node.id === nodeId) {
183
+ return nodes.splice(index, 1)[0];
184
+ }
185
+
186
+ const removedNode = removeTreeNode({
187
+ nodes: node.children,
188
+ nodeId,
189
+ });
190
+
191
+ if (removedNode) {
192
+ return removedNode;
193
+ }
194
+ }
195
+
196
+ return undefined;
197
+ };
198
+
199
+ const walkTree = ({ nodes, visit }) => {
200
+ if (!Array.isArray(nodes)) {
201
+ return;
202
+ }
203
+
204
+ for (const node of nodes) {
205
+ if (!node || typeof node !== "object") {
206
+ continue;
207
+ }
208
+
209
+ visit(node);
210
+ walkTree({
211
+ nodes: node.children,
212
+ visit,
213
+ });
214
+ }
215
+ };
216
+
217
+ export const collectTreeDescendantIds = ({ node, includeRoot = true }) => {
218
+ const ids = [];
219
+
220
+ if (!node || typeof node !== "object") {
221
+ return ids;
222
+ }
223
+
224
+ walkTree({
225
+ nodes: [node],
226
+ visit: (entry) => {
227
+ if (entry === node && includeRoot === false) {
228
+ return;
229
+ }
230
+ ids.push(entry.id);
231
+ },
232
+ });
233
+
234
+ return ids;
235
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export {
2
+ SCHEMA_VERSION,
3
+ processCommand,
4
+ validateAgainstState,
5
+ validatePayload,
6
+ validateState,
7
+ } from "./model.js";