@cloud411716/fancy-webnovel 2.2.0 → 3.0.3
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/README.md +65 -0
- package/client.js +201 -15
- package/cordis.patch.yml +5 -1
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +12 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +31 -7
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +145 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story graph store: CRUD, edges, requirements, change sets, decisions,
|
|
3
|
+
* validation and the work queue.
|
|
4
|
+
*
|
|
5
|
+
* All writes go through `apply()` so data + decisions are one transaction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ENTITIES,
|
|
10
|
+
EDGES,
|
|
11
|
+
TABLE_TO_ENTITY,
|
|
12
|
+
TABLE_TO_EDGE,
|
|
13
|
+
entityFor,
|
|
14
|
+
edgeFor,
|
|
15
|
+
} from './definitions.js';
|
|
16
|
+
import {
|
|
17
|
+
recordLiteral,
|
|
18
|
+
parseRef,
|
|
19
|
+
refString,
|
|
20
|
+
surrealString,
|
|
21
|
+
uuid,
|
|
22
|
+
} from './ids.js';
|
|
23
|
+
import { StoryDbError, flattenError, STORY_RECORD } from './client.js';
|
|
24
|
+
|
|
25
|
+
const STORY = 'story:main';
|
|
26
|
+
|
|
27
|
+
const ID_FIELDS = {
|
|
28
|
+
volume: 'volume_id',
|
|
29
|
+
act: 'act_id',
|
|
30
|
+
line: 'line_id',
|
|
31
|
+
line_node: 'node_id',
|
|
32
|
+
ending: 'ending_id',
|
|
33
|
+
role_slot: 'role_id',
|
|
34
|
+
chapter: 'chapter_id',
|
|
35
|
+
outline: 'chapter_id',
|
|
36
|
+
chapter_text: 'chapter_id',
|
|
37
|
+
handoff: 'chapter_id',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SINGLETONS = {
|
|
41
|
+
story: 'main',
|
|
42
|
+
scale: 'main',
|
|
43
|
+
world_doc: 'world',
|
|
44
|
+
story_doc: 'story',
|
|
45
|
+
emotion_doc: 'emotion',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function defOf(kind) {
|
|
49
|
+
return ENTITIES[kind] || TABLE_TO_ENTITY[kind] || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function edgeOf(kind) {
|
|
53
|
+
return EDGES[kind] || TABLE_TO_EDGE[kind] || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fail(code, message, detail) {
|
|
57
|
+
throw new StoryDbError(code, message, detail);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function entityKey(kind) {
|
|
61
|
+
const def = defOf(kind);
|
|
62
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
63
|
+
return def.table;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeKind(kind) {
|
|
67
|
+
const def = defOf(kind);
|
|
68
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
69
|
+
return def.table;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// field validation
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function checkValue(field, value) {
|
|
77
|
+
if (value === undefined || value === null) return null;
|
|
78
|
+
switch (field.type) {
|
|
79
|
+
case 'string':
|
|
80
|
+
case 'text':
|
|
81
|
+
if (typeof value !== 'string') return '必须是字符串';
|
|
82
|
+
if (field.required && !value.trim()) return '不能为空';
|
|
83
|
+
return null;
|
|
84
|
+
case 'int':
|
|
85
|
+
if (!Number.isInteger(Number(value))) return '必须是整数';
|
|
86
|
+
return null;
|
|
87
|
+
case 'float':
|
|
88
|
+
if (!Number.isFinite(Number(value))) return '必须是数字';
|
|
89
|
+
return null;
|
|
90
|
+
case 'bool':
|
|
91
|
+
if (typeof value !== 'boolean') return '必须是布尔值';
|
|
92
|
+
return null;
|
|
93
|
+
case 'datetime':
|
|
94
|
+
if (typeof value !== 'string' && !(value instanceof Date)) return '必须是日期时间';
|
|
95
|
+
return null;
|
|
96
|
+
case 'array':
|
|
97
|
+
if (!Array.isArray(value)) return '必须是数组';
|
|
98
|
+
if (field.required && field.nonEmpty && value.length === 0) return '不能为空数组';
|
|
99
|
+
return null;
|
|
100
|
+
case 'object':
|
|
101
|
+
if (typeof value !== 'object' || Array.isArray(value)) return '必须是对象';
|
|
102
|
+
return null;
|
|
103
|
+
case 'record':
|
|
104
|
+
if (typeof value !== 'string') return '必须是记录 ID 字符串';
|
|
105
|
+
return null;
|
|
106
|
+
case 'enum':
|
|
107
|
+
if (!field.values.includes(value)) return '必须是 ' + field.values.join(' / ');
|
|
108
|
+
return null;
|
|
109
|
+
default:
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function validateData(kind, data, opts = {}) {
|
|
115
|
+
const def = ENTITIES[kind] || EDGES[kind] || TABLE_TO_ENTITY[kind] || TABLE_TO_EDGE[kind];
|
|
116
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
117
|
+
const errors = [];
|
|
118
|
+
const partial = Boolean(opts.partial);
|
|
119
|
+
for (const [name, field] of Object.entries(def.fields || {})) {
|
|
120
|
+
const value = data[name];
|
|
121
|
+
if (value === undefined || value === null || value === '') {
|
|
122
|
+
if (!partial && field.required) {
|
|
123
|
+
errors.push({ field: name, code: 'REQUIRED', message: name + ' 必填' });
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const msg = checkValue(field, value);
|
|
128
|
+
if (msg) errors.push({ field: name, code: 'TYPE', message: name + ' ' + msg });
|
|
129
|
+
}
|
|
130
|
+
return errors;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeData(kind, data) {
|
|
134
|
+
const def = defOf(kind);
|
|
135
|
+
const out = { ...data };
|
|
136
|
+
for (const [name, field] of Object.entries(def.fields || {})) {
|
|
137
|
+
if (out[name] === undefined || out[name] === null) {
|
|
138
|
+
if (field.default !== undefined) out[name] = field.default;
|
|
139
|
+
else if (field.type === 'array') out[name] = [];
|
|
140
|
+
else if (field.type === 'object') out[name] = {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (out.status === undefined) out.status = 'seed';
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// ids
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
export function idFor(kind, data) {
|
|
152
|
+
const def = defOf(kind);
|
|
153
|
+
if (SINGLETONS[kind]) return SINGLETONS[kind];
|
|
154
|
+
if (ID_FIELDS[kind]) {
|
|
155
|
+
const value = data[ID_FIELDS[kind]] || data.id || data.entity_id;
|
|
156
|
+
if (!value) fail('ID_REQUIRED', kind + ' 缺少 ' + ID_FIELDS[kind]);
|
|
157
|
+
return String(value);
|
|
158
|
+
}
|
|
159
|
+
if (kind === 'scene') {
|
|
160
|
+
if (!data.outline_id) fail('ID_REQUIRED', 'scene 缺少 outline_id');
|
|
161
|
+
return 'SC_' + String(data.outline_id) + '_' + String(data.scene_order || 0);
|
|
162
|
+
}
|
|
163
|
+
if (data.id || data.entity_id) return String(data.id || data.entity_id);
|
|
164
|
+
fail('ID_REQUIRED', kind + ' 缺少 id');
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fieldsForWrite(kind, data) {
|
|
169
|
+
const def = ENTITIES[kind] || EDGES[kind] || TABLE_TO_ENTITY[kind] || TABLE_TO_EDGE[kind];
|
|
170
|
+
const out = {};
|
|
171
|
+
for (const name of Object.keys(def.fields || {})) {
|
|
172
|
+
if (data[name] !== undefined) out[name] = data[name];
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// requirements
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
async function ensureRequirements(sdb, kind, id, data) {
|
|
182
|
+
const def = defOf(kind);
|
|
183
|
+
if (!def || !def.requirements || !def.requirements.length) return;
|
|
184
|
+
const subject = def.table + ':' + id;
|
|
185
|
+
const existing = await sdb.rows(
|
|
186
|
+
`SELECT * FROM requirement WHERE story = ${STORY} AND subject = $subject;`,
|
|
187
|
+
{ subject },
|
|
188
|
+
);
|
|
189
|
+
const have = new Set(existing.map((row) => row.kind));
|
|
190
|
+
for (const req of def.requirements) {
|
|
191
|
+
if (have.has(req.kind)) continue;
|
|
192
|
+
const due = req.due === 'chapter'
|
|
193
|
+
? (data.due_chapter !== undefined ? Number(data.due_chapter) : undefined)
|
|
194
|
+
: undefined;
|
|
195
|
+
await sdb.query(
|
|
196
|
+
`UPSERT type::record('requirement:⟨' + $id + '⟩') SET
|
|
197
|
+
story = ${STORY},
|
|
198
|
+
kind = $kind,
|
|
199
|
+
subject = $subject,
|
|
200
|
+
status = 'pending',
|
|
201
|
+
hard = $hard,
|
|
202
|
+
due_chapter = $due_chapter,
|
|
203
|
+
label = $label,
|
|
204
|
+
payload = {};`,
|
|
205
|
+
{
|
|
206
|
+
id: uuid(),
|
|
207
|
+
kind: req.kind,
|
|
208
|
+
subject,
|
|
209
|
+
hard: req.hard !== false,
|
|
210
|
+
due_chapter: due,
|
|
211
|
+
label: req.label || req.kind,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function requirementsFor(sdb, subject) {
|
|
218
|
+
return sdb.rows(
|
|
219
|
+
`SELECT * FROM requirement WHERE story = ${STORY} AND subject = $subject ORDER BY kind;`,
|
|
220
|
+
{ subject },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// CRUD
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async function upsertChapterMetric(sdb, chapterId, displayNo, data) {
|
|
229
|
+
const chapLit = recordLiteral('chapter', chapterId);
|
|
230
|
+
await sdb.query(
|
|
231
|
+
`UPSERT chapter_metric SET
|
|
232
|
+
story = ${STORY},
|
|
233
|
+
chapter = ${chapLit},
|
|
234
|
+
chapter_no = $chapter_no,
|
|
235
|
+
emotion_value = $emotion_value,
|
|
236
|
+
hook_strength = $hook_strength,
|
|
237
|
+
word_target = $word_target
|
|
238
|
+
WHERE story = ${STORY} AND chapter_no = $chapter_no;`,
|
|
239
|
+
{
|
|
240
|
+
chapter_no: Number(displayNo),
|
|
241
|
+
emotion_value: Number(data.emotion && data.emotion.value) || undefined,
|
|
242
|
+
hook_strength: data.hook && data.hook.strength || undefined,
|
|
243
|
+
word_target: Number(data.word_target) || 0,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function resetKnowledgeState(sdb, charLit, subject, state) {
|
|
249
|
+
await sdb.query(
|
|
250
|
+
`DELETE knowledge_state WHERE story = ${STORY}
|
|
251
|
+
AND character = ${charLit} AND subject_type = $subject_type AND subject_id = $subject_id;`,
|
|
252
|
+
subject,
|
|
253
|
+
);
|
|
254
|
+
if (state) {
|
|
255
|
+
await sdb.query(
|
|
256
|
+
`CREATE knowledge_state SET story = ${STORY},
|
|
257
|
+
character = ${charLit}, subject_type = $subject_type,
|
|
258
|
+
subject_id = $subject_id, state = $state;`,
|
|
259
|
+
{ ...subject, state },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Write one knowledge change. Re-submitting the same chapter + character +
|
|
266
|
+
* subject replaces that chapter's event instead of appending a duplicate; the
|
|
267
|
+
* materialized `knowledge_state` is recomputed before and after the write so
|
|
268
|
+
* earlier-chapter rewrites do not trip the chronology guard.
|
|
269
|
+
*/
|
|
270
|
+
export async function addKnowledgeEvent(sdb, input) {
|
|
271
|
+
const data = input || {};
|
|
272
|
+
if (!data.chapter_id && !data.chapter) fail('VALIDATION', 'knowledge_event 缺少 chapter_id');
|
|
273
|
+
if (!data.subject_id || !data.character_id) fail('VALIDATION', 'knowledge_event 缺少 subject_id / character_id');
|
|
274
|
+
const chapterId = data.chapter_id || String(data.chapter).split(':').pop();
|
|
275
|
+
let chapterNo = Number(data.chapter_no);
|
|
276
|
+
if (!chapterNo) {
|
|
277
|
+
const chapter = await sdb.one(
|
|
278
|
+
`SELECT display_no FROM chapter WHERE story = ${STORY} AND chapter_id = $id LIMIT 1;`,
|
|
279
|
+
{ id: chapterId },
|
|
280
|
+
);
|
|
281
|
+
if (!chapter) fail('BAD_REF', '章节不存在:chapter:' + chapterId);
|
|
282
|
+
chapterNo = Number(chapter.display_no);
|
|
283
|
+
}
|
|
284
|
+
const sourceId = data.source_character_id || (data.source_character ? String(data.source_character).split(':').pop() : null);
|
|
285
|
+
const sourceExpr = sourceId ? recordLiteral('character', sourceId) : 'NONE';
|
|
286
|
+
const chapterLit = recordLiteral('chapter', chapterId);
|
|
287
|
+
const charLit = recordLiteral('character', data.character_id);
|
|
288
|
+
const subject = {
|
|
289
|
+
subject_type: data.subject_type || 'information',
|
|
290
|
+
subject_id: String(data.subject_id),
|
|
291
|
+
};
|
|
292
|
+
const key = {
|
|
293
|
+
...subject,
|
|
294
|
+
chapter_no: chapterNo,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// A chapter rewrite replaces its own knowledge event, not appends another.
|
|
298
|
+
await sdb.query(
|
|
299
|
+
`DELETE knowledge_event WHERE story = ${STORY} AND chapter = ${chapterLit}
|
|
300
|
+
AND character = ${charLit} AND subject_type = $subject_type AND subject_id = $subject_id;`,
|
|
301
|
+
subject,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// The chronology guard must see the state as of the previous chapter, even
|
|
305
|
+
// when events from later chapters already exist.
|
|
306
|
+
const before = await sdb.rows(
|
|
307
|
+
`SELECT coverage FROM knowledge_event WHERE story = ${STORY}
|
|
308
|
+
AND character = ${charLit} AND subject_type = $subject_type AND subject_id = $subject_id
|
|
309
|
+
AND chapter_no < $chapter_no
|
|
310
|
+
ORDER BY chapter_no DESC, created_at DESC LIMIT 1;`,
|
|
311
|
+
key,
|
|
312
|
+
);
|
|
313
|
+
await resetKnowledgeState(sdb, charLit, subject, before[0] ? before[0].coverage : null);
|
|
314
|
+
|
|
315
|
+
await sdb.query(
|
|
316
|
+
`CREATE knowledge_event SET
|
|
317
|
+
story = ${STORY},
|
|
318
|
+
chapter = ${chapterLit},
|
|
319
|
+
chapter_no = $chapter_no,
|
|
320
|
+
subject_type = $subject_type,
|
|
321
|
+
subject_id = $subject_id,
|
|
322
|
+
character = ${charLit},
|
|
323
|
+
character_id = $character_id,
|
|
324
|
+
change = $change,
|
|
325
|
+
coverage = $coverage,
|
|
326
|
+
knowledge = $knowledge,
|
|
327
|
+
how = $how,
|
|
328
|
+
source_character = ${sourceExpr},
|
|
329
|
+
scene = $scene,
|
|
330
|
+
evidence = $evidence,
|
|
331
|
+
payload = {};`,
|
|
332
|
+
{
|
|
333
|
+
...key,
|
|
334
|
+
character_id: String(data.character_id),
|
|
335
|
+
change: data.change || 'learns',
|
|
336
|
+
coverage: data.coverage || 'partial',
|
|
337
|
+
knowledge: data.knowledge || '',
|
|
338
|
+
how: data.how || 'other',
|
|
339
|
+
scene: data.scene || '',
|
|
340
|
+
evidence: data.evidence || '',
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Later chapters may already hold a newer state; the materialized row must
|
|
345
|
+
// reflect the newest event after the rewrite.
|
|
346
|
+
const latest = await sdb.rows(
|
|
347
|
+
`SELECT coverage FROM knowledge_event WHERE story = ${STORY}
|
|
348
|
+
AND character = ${charLit} AND subject_type = $subject_type AND subject_id = $subject_id
|
|
349
|
+
ORDER BY chapter_no DESC, created_at DESC LIMIT 1;`,
|
|
350
|
+
subject,
|
|
351
|
+
);
|
|
352
|
+
await resetKnowledgeState(sdb, charLit, subject, latest[0] ? latest[0].coverage : null);
|
|
353
|
+
|
|
354
|
+
return { ok: true };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function createEntity(sdb, kind, input) {
|
|
358
|
+
const def = defOf(kind);
|
|
359
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
360
|
+
const data = normalizeData(kind, input || {});
|
|
361
|
+
const errors = validateData(kind, data);
|
|
362
|
+
if (errors.length) {
|
|
363
|
+
fail('VALIDATION', kind + ' 字段不合法:' + errors.map((e) => e.message).join(';'), errors);
|
|
364
|
+
}
|
|
365
|
+
const id = idFor(kind, data);
|
|
366
|
+
const table = def.table;
|
|
367
|
+
const fields = fieldsForWrite(kind, data);
|
|
368
|
+
const payload = input || {};
|
|
369
|
+
const sets = [];
|
|
370
|
+
const vars = { id, payload };
|
|
371
|
+
if (table !== 'story') {
|
|
372
|
+
sets.push(`story = ${STORY}`, 'entity_id = $entity_id');
|
|
373
|
+
vars.entity_id = id;
|
|
374
|
+
}
|
|
375
|
+
sets.push('payload = $payload');
|
|
376
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
377
|
+
sets.push(name + ' = $' + name);
|
|
378
|
+
vars[name] = value;
|
|
379
|
+
}
|
|
380
|
+
await sdb.query(
|
|
381
|
+
`UPSERT type::record(${surrealString(table + ':⟨')} + $id + '⟩') SET ${sets.join(', ')};`,
|
|
382
|
+
vars,
|
|
383
|
+
);
|
|
384
|
+
await ensureRequirements(sdb, kind, id, data);
|
|
385
|
+
if (kind === 'chapter') await upsertChapterMetric(sdb, id, data.display_no, data);
|
|
386
|
+
if (kind === 'chapter_text' && data.display_no) {
|
|
387
|
+
await sdb.query(
|
|
388
|
+
`UPDATE chapter_metric SET word_count = $word_count WHERE story = ${STORY} AND chapter_no = $chapter_no;`,
|
|
389
|
+
{
|
|
390
|
+
word_count: Number(data.word_count) || String(data.content || '').replace(/\s/g, '').length,
|
|
391
|
+
chapter_no: Number(data.display_no),
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return { id: table + ':' + id, kind, table };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function updateEntity(sdb, kind, id, patch) {
|
|
399
|
+
const def = defOf(kind);
|
|
400
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
401
|
+
const errors = validateData(kind, patch || {}, { partial: true });
|
|
402
|
+
if (errors.length) {
|
|
403
|
+
fail('VALIDATION', kind + ' 字段不合法:' + errors.map((e) => e.message).join(';'), errors);
|
|
404
|
+
}
|
|
405
|
+
const fields = fieldsForWrite(kind, patch || {});
|
|
406
|
+
const sets = ['updated_at = time::now()'];
|
|
407
|
+
const vars = { id };
|
|
408
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
409
|
+
sets.push(name + ' = $' + name);
|
|
410
|
+
vars[name] = value;
|
|
411
|
+
}
|
|
412
|
+
if (!Object.keys(fields).length) return { id: def.table + ':' + id, changed: 0 };
|
|
413
|
+
await sdb.query(
|
|
414
|
+
`UPDATE type::record(${surrealString(def.table + ':⟨')} + $id + '⟩') SET ${sets.join(', ')};`,
|
|
415
|
+
vars,
|
|
416
|
+
);
|
|
417
|
+
if (kind === 'chapter' && (patch.emotion || patch.hook || patch.word_target !== undefined)) {
|
|
418
|
+
await upsertChapterMetric(sdb, id, patch.display_no || vars.display_no, patch);
|
|
419
|
+
}
|
|
420
|
+
return { id: def.table + ':' + id, changed: Object.keys(fields).length };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function getEntity(sdb, kind, id) {
|
|
424
|
+
const def = defOf(kind);
|
|
425
|
+
if (!def) fail('UNKNOWN_KIND', '未知实体:' + kind);
|
|
426
|
+
const row = await sdb.one(`SELECT * FROM ${recordLiteral(def.table, id)};`);
|
|
427
|
+
if (!row) fail('NOT_FOUND', '未找到 ' + def.table + ':' + id);
|
|
428
|
+
return row;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function searchEntities(sdb, kind, opts = {}) {
|
|
432
|
+
const def = defOf(kind);
|
|
433
|
+
const edge = edgeOf(kind);
|
|
434
|
+
if (!def && !edge) fail('UNKNOWN_KIND', '未知实体或边:' + kind);
|
|
435
|
+
const target = def || edge;
|
|
436
|
+
const where = [(def && kind === 'story') ? null : `story = ${STORY}`].filter(Boolean);
|
|
437
|
+
const vars = {};
|
|
438
|
+
const fields = target.fields || {};
|
|
439
|
+
for (const [key, value] of Object.entries(opts.where || {})) {
|
|
440
|
+
const allowed = key in fields || (edge && ['from_id', 'to_id'].includes(key));
|
|
441
|
+
if (!allowed) continue;
|
|
442
|
+
where.push(key + ' = $w_' + key);
|
|
443
|
+
vars['w_' + key] = value;
|
|
444
|
+
}
|
|
445
|
+
const limit = Math.max(1, Math.min(Number(opts.limit) || 50, 500));
|
|
446
|
+
const sql = `SELECT * FROM ${target.table}${where.length ? ' WHERE ' + where.join(' AND ') : ''} LIMIT ${limit};`;
|
|
447
|
+
return sdb.rows(sql, vars);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// edges
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
function endpoint(value, fallbackTable) {
|
|
455
|
+
const ref = parseRef(value, fallbackTable);
|
|
456
|
+
if (!ref) fail('BAD_REF', '无法解析记录引用:' + value);
|
|
457
|
+
return ref;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function linkEdge(sdb, edgeKind, fromValue, toValue, input) {
|
|
461
|
+
const edge = edgeOf(edgeKind);
|
|
462
|
+
if (!edge) fail('UNKNOWN_EDGE', '未知边:' + edgeKind);
|
|
463
|
+
const from = endpoint(fromValue, edge.in);
|
|
464
|
+
const to = endpoint(toValue, edge.out);
|
|
465
|
+
if (from.table !== edge.in) fail('BAD_EDGE_ENDPOINT', edge.table + ' 的起点必须是 ' + edge.in + ',收到 ' + from.table);
|
|
466
|
+
if (to.table !== edge.out) fail('BAD_EDGE_ENDPOINT', edge.table + ' 的终点必须是 ' + edge.out + ',收到 ' + to.table);
|
|
467
|
+
|
|
468
|
+
const data = { ...(input || {}) };
|
|
469
|
+
const errors = validateData(edgeKind, data);
|
|
470
|
+
if (errors.length) {
|
|
471
|
+
fail('VALIDATION', edge.table + ' 字段不合法:' + errors.map((e) => e.message).join(';'), errors);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Action edges need a chapter number for ordering checks.
|
|
475
|
+
if (edge.fills && data.chapter_no === undefined) {
|
|
476
|
+
if (from.table === 'chapter') {
|
|
477
|
+
const chapter = await sdb.one(
|
|
478
|
+
`SELECT display_no FROM chapter WHERE story = ${STORY} AND chapter_id = $id LIMIT 1;`,
|
|
479
|
+
{ id: from.id },
|
|
480
|
+
);
|
|
481
|
+
if (!chapter) fail('BAD_REF', '章节不存在:chapter:' + from.id);
|
|
482
|
+
data.chapter_no = Number(chapter.display_no);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const fromLit = recordLiteral(from.table, from.id);
|
|
487
|
+
const toLit = recordLiteral(to.table, to.id);
|
|
488
|
+
|
|
489
|
+
// Idempotency: remove the same edge before re-creating it.
|
|
490
|
+
if (edge.fills) {
|
|
491
|
+
await sdb.query(
|
|
492
|
+
`DELETE ${edge.table} WHERE story = ${STORY} AND in = ${fromLit} AND out = ${toLit} AND action = $action;`,
|
|
493
|
+
{ action: data.action },
|
|
494
|
+
);
|
|
495
|
+
} else {
|
|
496
|
+
await sdb.query(
|
|
497
|
+
`DELETE ${edge.table} WHERE story = ${STORY} AND in = ${fromLit} AND out = ${toLit};`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const sets = [`story = ${STORY}`, 'from_id = $from_id', 'to_id = $to_id', 'payload = $payload'];
|
|
502
|
+
const vars = { from_id: from.id, to_id: to.id, payload: input || {} };
|
|
503
|
+
for (const [name, value] of Object.entries(fieldsForWrite(edgeKind, data))) {
|
|
504
|
+
sets.push(name + ' = $' + name);
|
|
505
|
+
vars[name] = value;
|
|
506
|
+
}
|
|
507
|
+
await sdb.query(
|
|
508
|
+
`RELATE ${fromLit}->${edge.table}->${toLit} SET ${sets.join(', ')};`,
|
|
509
|
+
vars,
|
|
510
|
+
);
|
|
511
|
+
return { edge: edge.table, from: refString(from), to: refString(to) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function unlinkEdge(sdb, edgeKind, fromValue, toValue, data = {}) {
|
|
515
|
+
const edge = edgeOf(edgeKind);
|
|
516
|
+
if (!edge) fail('UNKNOWN_EDGE', '未知边:' + edgeKind);
|
|
517
|
+
const from = endpoint(fromValue, edge.in);
|
|
518
|
+
const to = endpoint(toValue, edge.out);
|
|
519
|
+
const fromLit = recordLiteral(from.table, from.id);
|
|
520
|
+
const toLit = recordLiteral(to.table, to.id);
|
|
521
|
+
const extra = data.action !== undefined ? ' AND action = $action' : '';
|
|
522
|
+
await sdb.query(
|
|
523
|
+
`DELETE ${edge.table} WHERE story = ${STORY} AND in = ${fromLit} AND out = ${toLit}${extra};`,
|
|
524
|
+
data.action !== undefined ? { action: data.action } : {},
|
|
525
|
+
);
|
|
526
|
+
return { removed: true };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// apply / change_set / decisions
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
const CHAPTER_EDGES = [
|
|
534
|
+
'chapter_cast',
|
|
535
|
+
'chapter_location',
|
|
536
|
+
'chapter_line_node',
|
|
537
|
+
'chapter_branch',
|
|
538
|
+
'chapter_conflict',
|
|
539
|
+
'foreshadow_action',
|
|
540
|
+
'payoff_action',
|
|
541
|
+
'info_action',
|
|
542
|
+
'ending_action',
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Replace the chapter-scoped graph of one chapter. A chapter submission is a
|
|
547
|
+
* complete unit: re-submitting it must not leave old cast/actions behind.
|
|
548
|
+
*/
|
|
549
|
+
async function clearChapterEdges(sdb, chapterId) {
|
|
550
|
+
const lit = recordLiteral('chapter', String(chapterId).replace(/^chapter:/, ''));
|
|
551
|
+
// has_chapter points act -> chapter, so it is cleared from the other side.
|
|
552
|
+
await sdb.query(`DELETE has_chapter WHERE story = ${STORY} AND out = ${lit};`);
|
|
553
|
+
for (const table of CHAPTER_EDGES) {
|
|
554
|
+
await sdb.query(
|
|
555
|
+
`DELETE ${table} WHERE story = ${STORY} AND in = ${lit};`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Submit several chapters in one CLI call. Each chapter is still its own
|
|
562
|
+
* transaction and its own change_set, so one bad chapter does not roll back
|
|
563
|
+
* the chapters before or after it. Rhythm chapters replace their own
|
|
564
|
+
* cast/action edges by default; pass `"replaceChapter": false` to append.
|
|
565
|
+
* Outline/write chapters leave the rhythm graph untouched.
|
|
566
|
+
*
|
|
567
|
+
* Payload:
|
|
568
|
+
* {
|
|
569
|
+
* "perChapter": true,
|
|
570
|
+
* "workflow": "rhythm",
|
|
571
|
+
* "chapters": [
|
|
572
|
+
* { "chapter": "CH001", "ops": [...], "decisions": [...] },
|
|
573
|
+
* ...
|
|
574
|
+
* ]
|
|
575
|
+
* }
|
|
576
|
+
*/
|
|
577
|
+
async function applyPerChapter(sdb, body) {
|
|
578
|
+
const chapters = Array.isArray(body.chapters) ? body.chapters : [];
|
|
579
|
+
if (!chapters.length) {
|
|
580
|
+
fail('BAD_INPUT', 'perChapter 需要非空的 chapters 数组');
|
|
581
|
+
}
|
|
582
|
+
if (chapters.length > 10) {
|
|
583
|
+
fail('BATCH_TOO_LARGE', '一次 apply --per-chapter 最多 10 章;请拆成多次提交');
|
|
584
|
+
}
|
|
585
|
+
const results = [];
|
|
586
|
+
for (let i = 0; i < chapters.length; i += 1) {
|
|
587
|
+
const item = chapters[i] || {};
|
|
588
|
+
const label = item.chapter || item.chapter_id || item.subject || ('#' + (i + 1));
|
|
589
|
+
try {
|
|
590
|
+
const workflow = item.workflow || body.workflow || 'per-chapter';
|
|
591
|
+
const replaceChapter = item.replaceChapter !== undefined
|
|
592
|
+
? item.replaceChapter === true
|
|
593
|
+
: workflow === 'rhythm';
|
|
594
|
+
const result = await apply(sdb, {
|
|
595
|
+
workflow,
|
|
596
|
+
summary: item.summary || body.summary || ('chapter ' + label),
|
|
597
|
+
parent: item.parent || body.parent,
|
|
598
|
+
chapter: label,
|
|
599
|
+
replaceChapter,
|
|
600
|
+
ops: Array.isArray(item.ops) ? item.ops : [],
|
|
601
|
+
decisions: Array.isArray(item.decisions) ? item.decisions : [],
|
|
602
|
+
supersedeSubjects: item.supersedeSubjects || body.supersedeSubjects,
|
|
603
|
+
});
|
|
604
|
+
results.push({ ok: true, chapter: label, ...result });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
results.push({
|
|
607
|
+
ok: false,
|
|
608
|
+
chapter: label,
|
|
609
|
+
error: {
|
|
610
|
+
code: err && err.code ? err.code : 'ERROR',
|
|
611
|
+
message: flattenError(err),
|
|
612
|
+
detail: err && err.detail !== undefined ? err.detail : null,
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const failed = results.filter((row) => row.ok !== true);
|
|
618
|
+
return {
|
|
619
|
+
ok: failed.length === 0,
|
|
620
|
+
per_chapter: true,
|
|
621
|
+
committed: results.length - failed.length,
|
|
622
|
+
failed: failed.length,
|
|
623
|
+
chapters: results,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Shape-only dry run used by `story_preview`. It never writes; the real
|
|
629
|
+
* `apply` still enforces every schedule, event, permission and uniqueness rule.
|
|
630
|
+
*/
|
|
631
|
+
export async function previewApply(sdb, payload) {
|
|
632
|
+
const body = payload || {};
|
|
633
|
+
const errors = [];
|
|
634
|
+
const ops = Array.isArray(body.ops) ? body.ops : [];
|
|
635
|
+
const decisions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
636
|
+
if (body.perChapter !== true && !Array.isArray(body.chapters) && !ops.length) {
|
|
637
|
+
errors.push({ code: 'OPS_EMPTY', message: '没有可写入的 ops' });
|
|
638
|
+
}
|
|
639
|
+
const check = (index, condition, code, message) => {
|
|
640
|
+
if (!condition) errors.push({ code, message: '第 ' + (index + 1) + ' 个操作:' + message, op_index: index });
|
|
641
|
+
};
|
|
642
|
+
for (let i = 0; i < ops.length; i += 1) {
|
|
643
|
+
const op = ops[i] || {};
|
|
644
|
+
if (op.op === 'create') {
|
|
645
|
+
check(i, typeof op.kind === 'string' && op.kind, 'BAD_OP', 'create 缺少 kind');
|
|
646
|
+
check(i, op.data && typeof op.data === 'object' && !Array.isArray(op.data), 'BAD_OP', 'create 缺少 data 对象');
|
|
647
|
+
} else if (op.op === 'update') {
|
|
648
|
+
check(i, typeof op.kind === 'string' && op.kind, 'BAD_OP', 'update 缺少 kind');
|
|
649
|
+
check(i, typeof op.id === 'string' && op.id, 'BAD_OP', 'update 缺少 id');
|
|
650
|
+
check(i, op.data && typeof op.data === 'object' && !Array.isArray(op.data), 'BAD_OP', 'update 缺少 data 对象');
|
|
651
|
+
} else if (op.op === 'link') {
|
|
652
|
+
check(i, typeof op.edge === 'string' && op.edge, 'BAD_OP', 'link 缺少 edge');
|
|
653
|
+
check(i, typeof op.from === 'string' && op.from, 'BAD_OP', 'link 缺少 from');
|
|
654
|
+
check(i, typeof op.to === 'string' && op.to, 'BAD_OP', 'link 缺少 to');
|
|
655
|
+
} else if (op.op === 'unlink') {
|
|
656
|
+
check(i, typeof op.edge === 'string' && op.edge, 'BAD_OP', 'unlink 缺少 edge');
|
|
657
|
+
check(i, typeof op.from === 'string' && op.from, 'BAD_OP', 'unlink 缺少 from');
|
|
658
|
+
check(i, typeof op.to === 'string' && op.to, 'BAD_OP', 'unlink 缺少 to');
|
|
659
|
+
} else if (op.op === 'knowledge') {
|
|
660
|
+
check(i, op.data && typeof op.data === 'object' && !Array.isArray(op.data), 'BAD_OP', 'knowledge 缺少 data 对象');
|
|
661
|
+
} else {
|
|
662
|
+
check(i, false, 'BAD_OP', '未知操作:' + String(op.op));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const decision of decisions) {
|
|
666
|
+
if (!decision || typeof decision !== 'object' || !decision.subject || !decision.text) {
|
|
667
|
+
errors.push({ code: 'BAD_DECISION', message: '每条 decision 都需要 subject 和 text' });
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const chapters = Array.isArray(body.chapters) ? body.chapters : [];
|
|
672
|
+
if (body.perChapter === true) {
|
|
673
|
+
if (!chapters.length) errors.push({ code: 'CHAPTERS_EMPTY', message: 'perChapter 模式需要 chapters 数组' });
|
|
674
|
+
if (chapters.length > 10) errors.push({ code: 'BATCH_TOO_LARGE', message: '单批最多 10 章,当前 ' + chapters.length + ' 章' });
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
ok: errors.length === 0,
|
|
678
|
+
operations: body.perChapter === true ? chapters.length : ops.length,
|
|
679
|
+
decisions: decisions.length,
|
|
680
|
+
chapters: chapters.length,
|
|
681
|
+
errors,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export async function apply(sdb, payload) {
|
|
686
|
+
const body = payload || {};
|
|
687
|
+
if (body.perChapter === true || Array.isArray(body.chapters)) {
|
|
688
|
+
return applyPerChapter(sdb, body);
|
|
689
|
+
}
|
|
690
|
+
const ops = Array.isArray(body.ops) ? body.ops : [];
|
|
691
|
+
const decisions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
692
|
+
const changeSetId = uuid();
|
|
693
|
+
const results = [];
|
|
694
|
+
|
|
695
|
+
return sdb.tx(async () => {
|
|
696
|
+
await sdb.query(
|
|
697
|
+
`UPSERT type::record('change_set:⟨' + $id + '⟩') SET
|
|
698
|
+
story = ${STORY},
|
|
699
|
+
workflow = $workflow,
|
|
700
|
+
summary = $summary,
|
|
701
|
+
status = 'committed',
|
|
702
|
+
parent = $parent,
|
|
703
|
+
payload = {};`,
|
|
704
|
+
{
|
|
705
|
+
id: changeSetId,
|
|
706
|
+
workflow: body.workflow || 'manual',
|
|
707
|
+
summary: body.summary || '',
|
|
708
|
+
parent: body.parent || undefined,
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
if (Array.isArray(body.supersedeSubjects) && body.supersedeSubjects.length) {
|
|
713
|
+
await sdb.query(
|
|
714
|
+
`UPDATE decision SET status = 'superseded'
|
|
715
|
+
WHERE story = ${STORY} AND subject IN $subjects AND status = 'active';`,
|
|
716
|
+
{ subjects: body.supersedeSubjects },
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (body.replaceChapter === true && body.chapter) {
|
|
721
|
+
await clearChapterEdges(sdb, body.chapter);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
for (let i = 0; i < ops.length; i += 1) {
|
|
725
|
+
const op = ops[i];
|
|
726
|
+
try {
|
|
727
|
+
if (op.op === 'create') {
|
|
728
|
+
results.push(await createEntity(sdb, op.kind, op.data));
|
|
729
|
+
} else if (op.op === 'update') {
|
|
730
|
+
if (op.edge) {
|
|
731
|
+
fail('USE_LINK_FOR_EDGE', '边不能使用 update;请用 link 重新写同一条边(会覆盖 data),或用 unlink 删除');
|
|
732
|
+
}
|
|
733
|
+
results.push(await updateEntity(sdb, op.kind, op.id, op.data));
|
|
734
|
+
} else if (op.op === 'link') {
|
|
735
|
+
results.push(await linkEdge(sdb, op.edge, op.from, op.to, op.data));
|
|
736
|
+
} else if (op.op === 'unlink') {
|
|
737
|
+
results.push(await unlinkEdge(sdb, op.edge, op.from, op.to, op.data));
|
|
738
|
+
} else if (op.op === 'knowledge') {
|
|
739
|
+
results.push(await addKnowledgeEvent(sdb, op.data));
|
|
740
|
+
} else {
|
|
741
|
+
fail('BAD_OP', '未知操作:' + op.op);
|
|
742
|
+
}
|
|
743
|
+
} catch (err) {
|
|
744
|
+
const message = flattenError(err);
|
|
745
|
+
fail('OP_FAILED', '第 ' + (i + 1) + ' 个操作失败:' + message, {
|
|
746
|
+
op_index: i,
|
|
747
|
+
op,
|
|
748
|
+
cause: message,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
for (const decision of decisions) {
|
|
754
|
+
await sdb.query(
|
|
755
|
+
`UPSERT type::record('decision:⟨' + $id + '⟩') SET
|
|
756
|
+
story = ${STORY},
|
|
757
|
+
change_set = $change_set,
|
|
758
|
+
scope = $scope,
|
|
759
|
+
subject = $subject,
|
|
760
|
+
text = $text,
|
|
761
|
+
source = $source,
|
|
762
|
+
reference = $reference,
|
|
763
|
+
reason = $reason,
|
|
764
|
+
status = 'active',
|
|
765
|
+
supersedes = $supersedes,
|
|
766
|
+
payload = {};`,
|
|
767
|
+
{
|
|
768
|
+
id: uuid(),
|
|
769
|
+
change_set: 'change_set:' + changeSetId,
|
|
770
|
+
scope: decision.scope || decision.subject || '',
|
|
771
|
+
subject: decision.subject || decision.scope || '',
|
|
772
|
+
text: decision.text || '',
|
|
773
|
+
source: decision.source || undefined,
|
|
774
|
+
reference: decision.reference || undefined,
|
|
775
|
+
reason: decision.reason || undefined,
|
|
776
|
+
supersedes: decision.supersedes || undefined,
|
|
777
|
+
},
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
change_set: 'change_set:' + changeSetId,
|
|
784
|
+
operations: ops.length,
|
|
785
|
+
decisions: decisions.length,
|
|
786
|
+
results,
|
|
787
|
+
};
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// validation
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
function parseSubject(subject) {
|
|
796
|
+
const idx = String(subject).indexOf(':');
|
|
797
|
+
if (idx < 0) return null;
|
|
798
|
+
return { table: subject.slice(0, idx), id: subject.slice(idx + 1) };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function subjectStatus(sdb, subject) {
|
|
802
|
+
const ref = parseSubject(subject);
|
|
803
|
+
if (!ref) return null;
|
|
804
|
+
try {
|
|
805
|
+
const row = await sdb.one(`SELECT status FROM ${recordLiteral(ref.table, ref.id)};`);
|
|
806
|
+
return row ? row.status : null;
|
|
807
|
+
} catch {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function validate(sdb, opts = {}) {
|
|
813
|
+
const scope = opts.scope || null;
|
|
814
|
+
try {
|
|
815
|
+
const out = await sdb.query('RETURN fn::fancy::validate($scope);', { scope: scope || undefined });
|
|
816
|
+
const value = Array.isArray(out) && out[0] && typeof out[0] === 'object' && !Array.isArray(out[0])
|
|
817
|
+
? out[0]
|
|
818
|
+
: (Array.isArray(out) && Array.isArray(out[0]) ? out[0][0] : null);
|
|
819
|
+
if (!value || typeof value !== 'object') {
|
|
820
|
+
fail('VALIDATION_RESULT', '数据库校验函数没有返回结果');
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
errors: Array.isArray(value.errors) ? value.errors : [],
|
|
824
|
+
pending: Array.isArray(value.pending) ? value.pending : [],
|
|
825
|
+
warnings: Array.isArray(value.warnings) ? value.warnings : [],
|
|
826
|
+
};
|
|
827
|
+
} catch (err) {
|
|
828
|
+
fail('VALIDATION_FAILED', '数据库校验失败:' + flattenError(err));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// work queue / decisions
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
export async function nextWork(sdb, limit = 20) {
|
|
837
|
+
const rows = await sdb.rows(
|
|
838
|
+
`SELECT * FROM requirement WHERE story = ${STORY} AND status = 'pending'
|
|
839
|
+
ORDER BY hard DESC, due_chapter ASC LIMIT $limit;`,
|
|
840
|
+
{ limit },
|
|
841
|
+
);
|
|
842
|
+
const out = [];
|
|
843
|
+
for (const row of rows) {
|
|
844
|
+
const ref = parseSubject(row.subject);
|
|
845
|
+
let subject = null;
|
|
846
|
+
if (ref) {
|
|
847
|
+
try {
|
|
848
|
+
subject = await sdb.one(`SELECT * FROM ${recordLiteral(ref.table, ref.id)};`);
|
|
849
|
+
} catch { /* missing */ }
|
|
850
|
+
}
|
|
851
|
+
out.push({
|
|
852
|
+
kind: row.kind,
|
|
853
|
+
subject: row.subject,
|
|
854
|
+
hard: Boolean(row.hard),
|
|
855
|
+
due_chapter: row.due_chapter,
|
|
856
|
+
label: row.label,
|
|
857
|
+
name: subject && (subject.name || subject.title || subject.info_name || subject.chapter_id) || null,
|
|
858
|
+
status: subject && subject.status || null,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export async function decisionsFor(sdb, subject, opts = {}) {
|
|
865
|
+
const where = [`story = ${STORY}`, 'subject = $subject'];
|
|
866
|
+
if (opts.activeOnly !== false) where.push("status = 'active'");
|
|
867
|
+
return sdb.rows(
|
|
868
|
+
`SELECT * FROM decision WHERE ${where.join(' AND ')} ORDER BY created_at DESC;`,
|
|
869
|
+
{ subject },
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
// read-only SQL
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
const READ_ONLY = /^\s*(SELECT|INFO|RETURN|SHOW|DESCRIBE|EXPLAIN)\b/i;
|
|
878
|
+
|
|
879
|
+
export async function readQuery(sdb, sql) {
|
|
880
|
+
const text = String(sql || '').trim().replace(/;+\s*$/, '');
|
|
881
|
+
if (!text) fail('QUERY_EMPTY', '查询语句为空');
|
|
882
|
+
if (!READ_ONLY.test(text) || /;\s*(DELETE|CREATE|UPDATE|UPSERT|REMOVE|DEFINE|RELATE|INSERT|ALTER|KILL)\b/i.test(text)) {
|
|
883
|
+
fail('QUERY_NOT_READ_ONLY', '只允许只读查询(SELECT / INFO / RETURN / SHOW / DESCRIBE / EXPLAIN)');
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
const out = await sdb.query(text + ';');
|
|
887
|
+
return Array.isArray(out) && out.length === 1 ? out[0] : out;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
fail('QUERY_FAILED', '查询失败:' + flattenError(err));
|
|
890
|
+
}
|
|
891
|
+
}
|