@ssobig/writer-cli 0.2.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.
Files changed (73) hide show
  1. package/README.md +35 -0
  2. package/asset-repository.js +278 -0
  3. package/config.js +14 -0
  4. package/package.json +28 -0
  5. package/project-runtime.js +102 -0
  6. package/storage-path.js +110 -0
  7. package/templates/mystery-v1/authoring-view-preference.js +34 -0
  8. package/templates/mystery-v1/character-perspective-preview.js +61 -0
  9. package/templates/mystery-v1/component-asset-operations.js +103 -0
  10. package/templates/mystery-v1/component-autosave.js +121 -0
  11. package/templates/mystery-v1/component-catalog-contract.js +340 -0
  12. package/templates/mystery-v1/component-checkpoint-history.js +145 -0
  13. package/templates/mystery-v1/component-contract.js +90 -0
  14. package/templates/mystery-v1/component-draft-operations.js +313 -0
  15. package/templates/mystery-v1/component-field-contracts.js +595 -0
  16. package/templates/mystery-v1/component-id-policy.js +64 -0
  17. package/templates/mystery-v1/component-manager.js +396 -0
  18. package/templates/mystery-v1/component-navigation-counts.js +64 -0
  19. package/templates/mystery-v1/component-registry.js +205 -0
  20. package/templates/mystery-v1/component-renderers.js +139 -0
  21. package/templates/mystery-v1/component-storage-contract.js +237 -0
  22. package/templates/mystery-v1/external-update-coordinator.js +91 -0
  23. package/templates/mystery-v1/output-clue-card-layout.js +46 -0
  24. package/templates/mystery-v1/page-header.js +26 -0
  25. package/templates/mystery-v1/render-ui-state.js +76 -0
  26. package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
  27. package/templates/mystery-v1/tab-bar.js +87 -0
  28. package/templates/mystery-v1/view-component-contract.js +152 -0
  29. package/templates/mystery-v1/view-component-registry.js +44 -0
  30. package/templates/mystery-v1/view-component-runtime.js +95 -0
  31. package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
  32. package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
  33. package/tools/writer-cli/package-lock.json +121 -0
  34. package/tools/writer-cli/package.json +22 -0
  35. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
  36. package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
  37. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
  38. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
  39. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
  40. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
  41. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
  42. package/tools/writer-cli/src/agent-paths.cjs +114 -0
  43. package/tools/writer-cli/src/agent-service.cjs +496 -0
  44. package/tools/writer-cli/src/asset-policy.cjs +113 -0
  45. package/tools/writer-cli/src/auth.cjs +655 -0
  46. package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
  47. package/tools/writer-cli/src/command-registry.cjs +152 -0
  48. package/tools/writer-cli/src/commands.cjs +841 -0
  49. package/tools/writer-cli/src/corpus.cjs +83 -0
  50. package/tools/writer-cli/src/daemon-app.cjs +106 -0
  51. package/tools/writer-cli/src/daemon-client.cjs +187 -0
  52. package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
  53. package/tools/writer-cli/src/daemon-runner.cjs +97 -0
  54. package/tools/writer-cli/src/daemon-server.cjs +378 -0
  55. package/tools/writer-cli/src/diagnostics.cjs +235 -0
  56. package/tools/writer-cli/src/domain.cjs +731 -0
  57. package/tools/writer-cli/src/errors.cjs +47 -0
  58. package/tools/writer-cli/src/gateway.cjs +357 -0
  59. package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
  60. package/tools/writer-cli/src/json-patch.cjs +98 -0
  61. package/tools/writer-cli/src/json.cjs +26 -0
  62. package/tools/writer-cli/src/local-index-cache.cjs +139 -0
  63. package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
  64. package/tools/writer-cli/src/local-index-query.cjs +304 -0
  65. package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
  66. package/tools/writer-cli/src/local-index-storage.cjs +284 -0
  67. package/tools/writer-cli/src/local-index.cjs +199 -0
  68. package/tools/writer-cli/src/mutations.cjs +722 -0
  69. package/tools/writer-cli/src/platform-runner.cjs +55 -0
  70. package/tools/writer-cli/src/project-import.cjs +485 -0
  71. package/tools/writer-cli/src/skill-manager.cjs +255 -0
  72. package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
  73. package/tools/writer-cli/src/update-gate.cjs +102 -0
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ const { cliError } = require("./errors.cjs");
4
+ const { own } = require("./local-index-snapshot.cjs");
5
+
6
+ const DEFAULT_FRESH_FOR_MS = 30_000;
7
+ const PUBLIC_PREVIEW_CHARACTERS = 200;
8
+
9
+ function rowToNode(row) {
10
+ if (!row) return null;
11
+ if (own(row, "matchId")) return row;
12
+ return {
13
+ matchId: row.match_id,
14
+ projectId: row.project_id,
15
+ projectSlug: row.project_slug,
16
+ versionNumber: Number(row.version_number),
17
+ componentSetRevision: Number(row.component_set_revision),
18
+ componentChecksum: row.component_checksum,
19
+ instanceId: row.instance_id,
20
+ templateId: row.template_id,
21
+ instanceRevision: Number(row.instance_revision),
22
+ dataHash: row.data_hash,
23
+ pointer: row.pointer,
24
+ field: row.field_name,
25
+ valueType: row.value_type,
26
+ valueJson: row.value_json,
27
+ valueText: row.value_text,
28
+ searchText: row.search_text,
29
+ foldedText: row.folded_text
30
+ };
31
+ }
32
+
33
+ function freshnessFor(metadata, options = {}, nowProvider = Date.now) {
34
+ if (!metadata) return Object.freeze({ status: "empty", syncedAt: null, ageMs: null, maxAgeMs: null });
35
+ const nowValue = options.now === undefined ? Number(nowProvider()) : new Date(options.now).getTime();
36
+ if (!Number.isFinite(nowValue)) throw cliError("E_LOCAL_INDEX_QUERY", "freshness now 값이 올바르지 않습니다.");
37
+ const maxAgeMs = options.maxAgeMs === undefined ? DEFAULT_FRESH_FOR_MS : Number(options.maxAgeMs);
38
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw cliError("E_LOCAL_INDEX_QUERY", "maxAgeMs는 0 이상의 숫자여야 합니다.");
39
+ const ageMs = Math.max(0, nowValue - new Date(metadata.syncedAt).getTime());
40
+ return Object.freeze({
41
+ status: options.online === false ? "offline" : (ageMs <= maxAgeMs ? "fresh" : "stale"),
42
+ syncedAt: metadata.syncedAt,
43
+ lastVerifiedAuthAt: metadata.lastVerifiedAuthAt,
44
+ ageMs,
45
+ maxAgeMs
46
+ });
47
+ }
48
+
49
+ function clippedText(value, maximum) {
50
+ const characters = [...String(value)];
51
+ return {
52
+ text: characters.length <= maximum ? characters.join("") : `${characters.slice(0, maximum).join("")}…`,
53
+ truncated: characters.length > maximum
54
+ };
55
+ }
56
+
57
+ function publicMatch(node) {
58
+ const parsedValue = JSON.parse(node.valueJson);
59
+ const preview = clippedText(node.valueText, PUBLIC_PREVIEW_CHARACTERS);
60
+ const valueIncluded = typeof parsedValue !== "string" || !preview.truncated;
61
+ return Object.freeze({
62
+ matchId: node.matchId,
63
+ project: Object.freeze({ id: node.projectId, slug: node.projectSlug }),
64
+ versionNumber: node.versionNumber,
65
+ component: Object.freeze({
66
+ instanceId: node.instanceId,
67
+ templateId: node.templateId,
68
+ revision: node.instanceRevision,
69
+ dataHash: node.dataHash
70
+ }),
71
+ isAsset: node.templateId === "ssobig.assets",
72
+ componentSet: Object.freeze({ revision: node.componentSetRevision, checksum: node.componentChecksum }),
73
+ pointer: node.pointer,
74
+ field: node.field,
75
+ valueType: node.valueType,
76
+ ...(valueIncluded ? { value: parsedValue } : {}),
77
+ valueIncluded,
78
+ ...(!valueIncluded ? { preview: preview.text, previewTruncated: true } : {})
79
+ });
80
+ }
81
+
82
+ function componentFromRow(row) {
83
+ if (own(row, "projectId")) return row;
84
+ return {
85
+ projectId: row.project_id,
86
+ projectSlug: row.project_slug,
87
+ versionNumber: Number(row.version_number),
88
+ componentSetRevision: Number(row.component_set_revision),
89
+ componentChecksum: row.component_checksum,
90
+ instanceId: row.instance_id,
91
+ templateId: row.template_id,
92
+ instanceRevision: Number(row.instance_revision),
93
+ dataHash: row.data_hash,
94
+ data: JSON.parse(row.data_json)
95
+ };
96
+ }
97
+
98
+ module.exports = Object.freeze({ rowToNode, freshnessFor, publicMatch, componentFromRow });
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+
3
+ const { cliError } = require("./errors.cjs");
4
+ const {
5
+ own,
6
+ isObject,
7
+ finiteInteger,
8
+ assertJsonValue,
9
+ cloneJson,
10
+ unescapeJsonPointerToken,
11
+ scalarType,
12
+ normalizedText,
13
+ foldedText,
14
+ trigramValues
15
+ } = require("./local-index-snapshot.cjs");
16
+
17
+ const DEFAULT_LIMIT = 20;
18
+ const DEFAULT_MAX_LIMIT = 500;
19
+ const DEFAULT_MAX_CANDIDATE_ROWS = 50_000;
20
+ const DEFAULT_MAX_REGEX_SCAN_ROWS = 10_000;
21
+ const DEFAULT_MAX_REGEX_PATTERN_LENGTH = 256;
22
+ const DEFAULT_MAX_REGEX_VALUE_LENGTH = 64 * 1024;
23
+ const DEFAULT_MAX_SUBSTRING_TRIGRAMS = 64;
24
+
25
+ function createQueryLimits(options, useFts5) {
26
+ return Object.freeze({
27
+ maxLimit: finiteInteger(options.maxLimit ?? DEFAULT_MAX_LIMIT, "maxLimit", 1, "E_LOCAL_INDEX_CONFIGURATION"),
28
+ maxCandidateRows: finiteInteger(options.maxCandidateRows ?? DEFAULT_MAX_CANDIDATE_ROWS, "maxCandidateRows", 1, "E_LOCAL_INDEX_CONFIGURATION"),
29
+ maxRegexScanRows: finiteInteger(options.maxRegexScanRows ?? DEFAULT_MAX_REGEX_SCAN_ROWS, "maxRegexScanRows", 1, "E_LOCAL_INDEX_CONFIGURATION"),
30
+ maxRegexPatternLength: finiteInteger(options.maxRegexPatternLength ?? DEFAULT_MAX_REGEX_PATTERN_LENGTH, "maxRegexPatternLength", 1, "E_LOCAL_INDEX_CONFIGURATION"),
31
+ maxRegexValueLength: finiteInteger(options.maxRegexValueLength ?? DEFAULT_MAX_REGEX_VALUE_LENGTH, "maxRegexValueLength", 1, "E_LOCAL_INDEX_CONFIGURATION"),
32
+ maxSubstringTrigrams: finiteInteger(options.maxSubstringTrigrams ?? DEFAULT_MAX_SUBSTRING_TRIGRAMS, "maxSubstringTrigrams", 1, "E_LOCAL_INDEX_CONFIGURATION"),
33
+ useFts5: useFts5 === true
34
+ });
35
+ }
36
+
37
+ function asStringList(value, name) {
38
+ if (value === undefined || value === null) return [];
39
+ const list = Array.isArray(value) ? value : [value];
40
+ const normalized = list.map(item => String(item || "").trim());
41
+ if (normalized.some(item => !item)) throw cliError("E_LOCAL_INDEX_QUERY", `${name} filter에 빈 값이 있습니다.`);
42
+ if (normalized.length > 256) throw cliError("E_LOCAL_INDEX_QUERY", `${name} filter는 최대 256개까지 지원합니다.`);
43
+ return [...new Set(normalized)];
44
+ }
45
+
46
+ function asVersionList(value) {
47
+ if (value === undefined || value === null) return [];
48
+ return [...new Set((Array.isArray(value) ? value : [value]).map(item => finiteInteger(item, "version filter", 1, "E_LOCAL_INDEX_QUERY")))];
49
+ }
50
+
51
+ function normalizePathFilter(item, defaultOperation = "exact") {
52
+ const object = isObject(item) ? item : { op: defaultOperation, value: item };
53
+ const op = String(object.op || object.mode || defaultOperation);
54
+ if (!["exact", "prefix", "glob"].includes(op)) throw cliError("E_LOCAL_INDEX_QUERY", `지원하지 않는 path filter입니다: ${op}`);
55
+ const value = String(object.value ?? "");
56
+ if (value !== "" && !value.startsWith("/")) throw cliError("E_LOCAL_INDEX_QUERY", "JSON pointer는 빈 문자열 또는 /로 시작해야 합니다.");
57
+ if (op !== "glob") {
58
+ for (const token of value.split("/").slice(1)) unescapeJsonPointerToken(token);
59
+ }
60
+ return { op, value };
61
+ }
62
+
63
+ function normalizeFilters(request) {
64
+ const projectRefs = asStringList(request.projects ?? request.project, "project");
65
+ const componentRefs = asStringList(request.components ?? request.component, "component");
66
+ const pathFilters = [];
67
+ if (own(request, "path")) pathFilters.push(normalizePathFilter(request.path, "exact"));
68
+ if (own(request, "pathPrefix")) pathFilters.push(normalizePathFilter(request.pathPrefix, "prefix"));
69
+ if (own(request, "pathGlob")) pathFilters.push(normalizePathFilter(request.pathGlob, "glob"));
70
+ if (request.paths !== undefined) {
71
+ if (!Array.isArray(request.paths)) throw cliError("E_LOCAL_INDEX_QUERY", "paths는 배열이어야 합니다.");
72
+ pathFilters.push(...request.paths.map(item => normalizePathFilter(item, "exact")));
73
+ }
74
+ return {
75
+ projectRefs,
76
+ projectIds: asStringList(request.projectIds ?? request.projectId, "projectId"),
77
+ projectSlugs: asStringList(request.projectSlugs ?? request.projectSlug, "projectSlug"),
78
+ versions: asVersionList(request.versions ?? request.version),
79
+ componentRefs,
80
+ instanceIds: asStringList(request.instanceIds ?? request.instanceId, "instanceId"),
81
+ templateIds: asStringList(request.templateIds ?? request.templateId, "templateId"),
82
+ fields: asStringList(request.fields ?? request.field, "field"),
83
+ valueTypes: asStringList(request.valueTypes ?? request.valueType, "valueType"),
84
+ pathFilters,
85
+ includeAssets: request.includeAssets === true
86
+ };
87
+ }
88
+
89
+ function addInSql(where, parameters, column, values) {
90
+ if (!values.length) return;
91
+ where.push(`${column} IN (${values.map(() => "?").join(", ")})`);
92
+ parameters.push(...values);
93
+ }
94
+
95
+ function appendSqlIdentityFilters(where, parameters, filters, tableName = "scalar_nodes") {
96
+ const prefix = tableName ? `${tableName}.` : "";
97
+ if (filters.projectRefs.length) {
98
+ const placeholders = filters.projectRefs.map(() => "?").join(", ");
99
+ where.push(`(${prefix}project_id IN (${placeholders}) OR ${prefix}project_slug IN (${placeholders}))`);
100
+ parameters.push(...filters.projectRefs, ...filters.projectRefs);
101
+ }
102
+ addInSql(where, parameters, `${prefix}project_id`, filters.projectIds);
103
+ addInSql(where, parameters, `${prefix}project_slug`, filters.projectSlugs);
104
+ addInSql(where, parameters, `${prefix}version_number`, filters.versions);
105
+ if (filters.componentRefs.length) {
106
+ const placeholders = filters.componentRefs.map(() => "?").join(", ");
107
+ where.push(`(${prefix}instance_id IN (${placeholders}) OR ${prefix}template_id IN (${placeholders}))`);
108
+ parameters.push(...filters.componentRefs, ...filters.componentRefs);
109
+ }
110
+ addInSql(where, parameters, `${prefix}instance_id`, filters.instanceIds);
111
+ addInSql(where, parameters, `${prefix}template_id`, filters.templateIds);
112
+ }
113
+
114
+ function sqlLikeLiteral(value) {
115
+ return String(value).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
116
+ }
117
+
118
+ function appendSqlNodeFilters(where, parameters, filters) {
119
+ appendSqlIdentityFilters(where, parameters, filters);
120
+ if (!filters.includeAssets) where.push("scalar_nodes.template_id <> 'ssobig.assets'");
121
+ addInSql(where, parameters, "scalar_nodes.field_name", filters.fields);
122
+ addInSql(where, parameters, "scalar_nodes.value_type", filters.valueTypes);
123
+ if (filters.pathFilters.length && filters.pathFilters.every(item => item.op !== "glob")) {
124
+ const clauses = [];
125
+ for (const filter of filters.pathFilters) {
126
+ if (filter.op === "exact") {
127
+ clauses.push("scalar_nodes.pointer = ?");
128
+ parameters.push(filter.value);
129
+ } else {
130
+ clauses.push("(scalar_nodes.pointer = ? OR scalar_nodes.pointer LIKE ? ESCAPE '\\')");
131
+ parameters.push(filter.value, `${sqlLikeLiteral(filter.value).replace(/\/$/, "")}/%`);
132
+ }
133
+ }
134
+ where.push(`(${clauses.join(" OR ")})`);
135
+ }
136
+ }
137
+
138
+ function globRegex(glob) {
139
+ let source = "^";
140
+ for (let index = 0; index < glob.length; index += 1) {
141
+ const character = glob[index];
142
+ if (character === "*" && glob[index + 1] === "*") {
143
+ source += ".*";
144
+ index += 1;
145
+ } else if (character === "*") source += "[^/]*";
146
+ else if (character === "?") source += "[^/]";
147
+ else source += character.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
148
+ }
149
+ return new RegExp(`${source}$`, "u");
150
+ }
151
+
152
+ function pathMatches(pointer, filters) {
153
+ if (!filters.length) return true;
154
+ return filters.some(filter => {
155
+ if (filter.op === "exact") return pointer === filter.value;
156
+ if (filter.op === "prefix") return pointer === filter.value || pointer.startsWith(`${filter.value.replace(/\/$/, "")}/`);
157
+ return globRegex(filter.value).test(pointer);
158
+ });
159
+ }
160
+
161
+ function identityMatches(node, filters) {
162
+ if (!filters.includeAssets && node.templateId === "ssobig.assets") return false;
163
+ if (filters.projectRefs.length && !filters.projectRefs.includes(node.projectId) && !filters.projectRefs.includes(node.projectSlug)) return false;
164
+ if (filters.projectIds.length && !filters.projectIds.includes(node.projectId)) return false;
165
+ if (filters.projectSlugs.length && !filters.projectSlugs.includes(node.projectSlug)) return false;
166
+ if (filters.versions.length && !filters.versions.includes(Number(node.versionNumber))) return false;
167
+ if (filters.componentRefs.length && !filters.componentRefs.includes(node.instanceId) && !filters.componentRefs.includes(node.templateId)) return false;
168
+ if (filters.instanceIds.length && !filters.instanceIds.includes(node.instanceId)) return false;
169
+ if (filters.templateIds.length && !filters.templateIds.includes(node.templateId)) return false;
170
+ if (filters.fields.length && !filters.fields.includes(node.field ?? node.field_name)) return false;
171
+ if (filters.valueTypes.length && !filters.valueTypes.includes(node.valueType ?? node.value_type)) return false;
172
+ return pathMatches(node.pointer, filters.pathFilters);
173
+ }
174
+
175
+ function assertSafeRegex(pattern, maxLength) {
176
+ if (pattern.length > maxLength) throw cliError("E_LOCAL_INDEX_REGEX_LIMIT", `regex는 최대 ${maxLength}자까지 지원합니다.`);
177
+ if (/\(\?[=!<]/u.test(pattern) || /\\(?:[1-9]|k<)/u.test(pattern)) {
178
+ throw cliError("E_LOCAL_INDEX_UNSAFE_REGEX", "lookaround와 backreference가 없는 제한된 regex만 지원합니다.");
179
+ }
180
+ if (/(?:\.\*){2,}|(?:\.\+){2,}/u.test(pattern.replace(/\s+/g, ""))) {
181
+ throw cliError("E_LOCAL_INDEX_UNSAFE_REGEX", "반복 wildcard가 중첩된 regex는 지원하지 않습니다.");
182
+ }
183
+ const stack = [];
184
+ let escaped = false;
185
+ let characterClass = false;
186
+ for (let index = 0; index < pattern.length; index += 1) {
187
+ const character = pattern[index];
188
+ if (escaped) { escaped = false; continue; }
189
+ if (character === "\\") { escaped = true; continue; }
190
+ if (character === "[") { characterClass = true; continue; }
191
+ if (character === "]") { characterClass = false; continue; }
192
+ if (characterClass) continue;
193
+ if (character === "(") stack.push({ quantified: false, alternation: false });
194
+ else if (character === "|") { if (stack.length) stack.at(-1).alternation = true; }
195
+ else if (["*", "+", "?", "{"].includes(character)) {
196
+ if (stack.length) stack.at(-1).quantified = true;
197
+ } else if (character === ")") {
198
+ const group = stack.pop() || { quantified: false, alternation: false };
199
+ const tail = pattern.slice(index + 1);
200
+ if (/^(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(tail) && (group.quantified || group.alternation)) {
201
+ throw cliError("E_LOCAL_INDEX_UNSAFE_REGEX", "중첩되거나 모호한 반복 group regex는 지원하지 않습니다.");
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ function compileRegex(request, limits) {
208
+ const pattern = String(request.query ?? request.pattern ?? "");
209
+ assertSafeRegex(pattern, limits.maxRegexPatternLength);
210
+ const requestedFlags = String(request.flags || "");
211
+ if (/[^imsu]/u.test(requestedFlags)) throw cliError("E_LOCAL_INDEX_QUERY", "regex flags는 i, m, s, u만 지원합니다.");
212
+ let flags = [...new Set([...requestedFlags, "u"])].join("");
213
+ if (request.caseSensitive !== true && !flags.includes("i")) flags += "i";
214
+ try { return new RegExp(pattern, flags); }
215
+ catch (error) { throw cliError("E_LOCAL_INDEX_QUERY", "올바르지 않은 regex입니다.", null, error); }
216
+ }
217
+
218
+ function normalizeSearch(request, limits) {
219
+ if (!isObject(request)) throw cliError("E_LOCAL_INDEX_QUERY", "검색 요청 객체가 필요합니다.");
220
+ const mode = String(request.mode || "substring");
221
+ if (!["exact", "substring", "regex", "all"].includes(mode)) throw cliError("E_LOCAL_INDEX_QUERY", `지원하지 않는 검색 mode입니다: ${mode}`);
222
+ const hasValue = own(request, "value");
223
+ if (mode !== "all" && !hasValue && !own(request, "query") && !own(request, "pattern")) {
224
+ throw cliError("E_LOCAL_INDEX_QUERY", "검색 query 또는 value가 필요합니다.");
225
+ }
226
+ if (hasValue) assertJsonValue(request.value);
227
+ if (hasValue && ![null, "string", "number", "boolean"].includes(request.value === null ? null : typeof request.value)) {
228
+ throw cliError("E_LOCAL_INDEX_QUERY", "exact value는 JSON scalar여야 합니다.");
229
+ }
230
+ if (hasValue && !["exact", "all"].includes(mode)) throw cliError("E_LOCAL_INDEX_QUERY", "typed value 검색은 exact mode에서만 지원합니다.");
231
+ const limit = finiteInteger(request.limit ?? Math.min(DEFAULT_LIMIT, limits.maxLimit), "limit", 1, "E_LOCAL_INDEX_QUERY");
232
+ if (limit > limits.maxLimit) throw cliError("E_LOCAL_INDEX_QUERY", `limit은 최대 ${limits.maxLimit}입니다.`);
233
+ const caseSensitive = request.caseSensitive === true;
234
+ const query = hasValue ? null : String(request.query ?? request.pattern ?? "");
235
+ const normalizedQuery = query === null ? null : normalizedText(query);
236
+ return {
237
+ request,
238
+ mode,
239
+ hasValue,
240
+ value: hasValue ? cloneJson(request.value) : undefined,
241
+ valueType: hasValue ? scalarType(request.value) : null,
242
+ valueJson: hasValue ? JSON.stringify(request.value) : null,
243
+ query: normalizedQuery,
244
+ foldedQuery: normalizedQuery === null ? null : foldedText(normalizedQuery),
245
+ caseSensitive,
246
+ limit,
247
+ filters: normalizeFilters(request),
248
+ regex: mode === "regex" ? compileRegex(request, limits) : null
249
+ };
250
+ }
251
+
252
+ function appendSqlSearch(where, parameters, search, limits) {
253
+ if (search.mode === "exact") {
254
+ if (search.hasValue) {
255
+ where.push("scalar_nodes.value_type = ? AND scalar_nodes.value_json = ?");
256
+ parameters.push(search.valueType, search.valueJson);
257
+ } else {
258
+ where.push(`scalar_nodes.${search.caseSensitive ? "search_text" : "folded_text"} = ?`);
259
+ parameters.push(search.caseSensitive ? search.query : search.foldedQuery);
260
+ }
261
+ } else if (search.mode === "substring") {
262
+ const needle = search.caseSensitive ? search.query : search.foldedQuery;
263
+ const trigrams = trigramValues(search.foldedQuery, limits.maxSubstringTrigrams);
264
+ if (trigrams.length) {
265
+ if (limits.useFts5) {
266
+ where.push("scalar_nodes.id IN (SELECT rowid FROM scalar_fts WHERE scalar_fts MATCH ?)");
267
+ parameters.push(`"${search.foldedQuery.replace(/"/g, "\"\"")}"`);
268
+ } else {
269
+ where.push(`scalar_nodes.id IN (
270
+ SELECT node_id FROM scalar_trigrams
271
+ WHERE trigram IN (${trigrams.map(() => "?").join(", ")})
272
+ GROUP BY node_id HAVING COUNT(*) = ?
273
+ )`);
274
+ parameters.push(...trigrams, trigrams.length);
275
+ }
276
+ }
277
+ where.push(`instr(scalar_nodes.${search.caseSensitive ? "search_text" : "folded_text"}, ?) > 0`);
278
+ parameters.push(needle);
279
+ }
280
+ }
281
+
282
+ function nodeMatchesSearch(node, search) {
283
+ if (!identityMatches(node, search.filters)) return false;
284
+ if (search.mode === "all") return true;
285
+ if (search.mode === "exact") {
286
+ if (search.hasValue) return node.valueType === search.valueType && node.valueJson === search.valueJson;
287
+ return search.caseSensitive ? node.searchText === search.query : node.foldedText === search.foldedQuery;
288
+ }
289
+ if (search.mode === "substring") {
290
+ return (search.caseSensitive ? node.searchText : node.foldedText).includes(search.caseSensitive ? search.query : search.foldedQuery);
291
+ }
292
+ return search.regex.test(node.valueText);
293
+ }
294
+
295
+ module.exports = Object.freeze({
296
+ createQueryLimits,
297
+ normalizeFilters,
298
+ appendSqlIdentityFilters,
299
+ appendSqlNodeFilters,
300
+ identityMatches,
301
+ normalizeSearch,
302
+ appendSqlSearch,
303
+ nodeMatchesSearch
304
+ });
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+
3
+ const { sha256 } = require("./json.cjs");
4
+ const { cliError } = require("./errors.cjs");
5
+
6
+ const INDEX_SCHEMA_VERSION = "ssobig-writer-local-read-index-v2";
7
+ const SERVER_SNAPSHOT_SOURCE = "supabase-authoritative-read";
8
+
9
+ function own(value, key) {
10
+ return Object.prototype.hasOwnProperty.call(value, key);
11
+ }
12
+
13
+ function isObject(value) {
14
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+
17
+ function finiteInteger(value, name, minimum = 0, errorCode = "E_LOCAL_INDEX_SNAPSHOT") {
18
+ const number = Number(value);
19
+ if (!Number.isSafeInteger(number) || number < minimum) {
20
+ throw cliError(errorCode, `${name} 값이 올바르지 않습니다.`);
21
+ }
22
+ return number;
23
+ }
24
+
25
+ function nonEmptyString(value, name) {
26
+ const text = String(value || "").trim();
27
+ if (!text) throw cliError("E_LOCAL_INDEX_SNAPSHOT", `${name} 값이 필요합니다.`);
28
+ return text;
29
+ }
30
+
31
+ function isoTimestamp(value, name) {
32
+ const text = String(value || "");
33
+ if (!text || Number.isNaN(new Date(text).getTime())) {
34
+ throw cliError("E_LOCAL_INDEX_SNAPSHOT", `${name} 값이 유효한 ISO 날짜가 아닙니다.`);
35
+ }
36
+ return new Date(text).toISOString();
37
+ }
38
+
39
+ function assertJsonValue(value, pointer = "") {
40
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
41
+ if (typeof value === "number") {
42
+ if (!Number.isFinite(value)) throw cliError("E_LOCAL_INDEX_SNAPSHOT", `JSON에 유한하지 않은 숫자가 있습니다: ${pointer || "/"}`);
43
+ return;
44
+ }
45
+ if (Array.isArray(value)) {
46
+ for (let index = 0; index < value.length; index += 1) assertJsonValue(value[index], `${pointer}/${index}`);
47
+ return;
48
+ }
49
+ if (isObject(value)) {
50
+ const prototype = Object.getPrototypeOf(value);
51
+ if (prototype !== Object.prototype && prototype !== null) {
52
+ throw cliError("E_LOCAL_INDEX_SNAPSHOT", `plain JSON object가 아닌 값이 있습니다: ${pointer || "/"}`);
53
+ }
54
+ for (const key of Object.keys(value)) assertJsonValue(value[key], `${pointer}/${escapeJsonPointerToken(key)}`);
55
+ return;
56
+ }
57
+ throw cliError("E_LOCAL_INDEX_SNAPSHOT", `JSON으로 저장할 수 없는 값이 있습니다: ${pointer || "/"}`);
58
+ }
59
+
60
+ function cloneJson(value) {
61
+ assertJsonValue(value);
62
+ return JSON.parse(JSON.stringify(value));
63
+ }
64
+
65
+ function escapeJsonPointerToken(value) {
66
+ return String(value).replace(/~/g, "~0").replace(/\//g, "~1");
67
+ }
68
+
69
+ function unescapeJsonPointerToken(value) {
70
+ const text = String(value);
71
+ if (/~(?![01])/u.test(text)) throw cliError("E_LOCAL_INDEX_QUERY", `잘못된 RFC 6901 token입니다: ${text}`);
72
+ return text.replace(/~1/g, "/").replace(/~0/g, "~");
73
+ }
74
+
75
+ function scalarType(value) {
76
+ if (value === null) return "null";
77
+ return typeof value;
78
+ }
79
+
80
+ function scalarText(value) {
81
+ if (typeof value === "string") return value;
82
+ if (value === null) return "null";
83
+ return String(value);
84
+ }
85
+
86
+ function normalizedText(value) {
87
+ return String(value).normalize("NFC");
88
+ }
89
+
90
+ function foldedText(value) {
91
+ return normalizedText(value).toLowerCase();
92
+ }
93
+
94
+ function flattenJsonScalars(value) {
95
+ assertJsonValue(value);
96
+ const rows = [];
97
+ function visit(current, pointer) {
98
+ if (current === null || ["string", "number", "boolean"].includes(typeof current)) {
99
+ const text = scalarText(current);
100
+ const encodedField = pointer === "" ? "" : pointer.slice(pointer.lastIndexOf("/") + 1);
101
+ rows.push(Object.freeze({
102
+ pointer,
103
+ field: pointer === "" ? "" : unescapeJsonPointerToken(encodedField),
104
+ value: current,
105
+ valueType: scalarType(current),
106
+ valueJson: JSON.stringify(current),
107
+ valueText: text,
108
+ searchText: normalizedText(text),
109
+ foldedText: foldedText(text)
110
+ }));
111
+ return;
112
+ }
113
+ if (Array.isArray(current)) {
114
+ current.forEach((item, index) => visit(item, `${pointer}/${index}`));
115
+ return;
116
+ }
117
+ for (const key of Object.keys(current)) visit(current[key], `${pointer}/${escapeJsonPointerToken(key)}`);
118
+ }
119
+ visit(value, "");
120
+ return rows;
121
+ }
122
+
123
+ function trigramValues(value, maxCount = Infinity) {
124
+ const characters = [...foldedText(value)];
125
+ if (characters.length < 3) return [];
126
+ const unique = new Set();
127
+ for (let index = 0; index <= characters.length - 3 && unique.size < maxCount; index += 1) {
128
+ unique.add(characters.slice(index, index + 3).join(""));
129
+ }
130
+ return [...unique];
131
+ }
132
+
133
+ function normalizeSnapshot(snapshot) {
134
+ const arrayInput = Array.isArray(snapshot);
135
+ const source = arrayInput ? SERVER_SNAPSHOT_SOURCE : String(snapshot?.source || "");
136
+ if (source !== SERVER_SNAPSHOT_SOURCE) {
137
+ throw cliError("E_LOCAL_INDEX_SNAPSHOT", `로컬 인덱스는 ${SERVER_SNAPSHOT_SOURCE} snapshot만 받습니다.`);
138
+ }
139
+ const syncedAt = isoTimestamp(arrayInput ? new Date().toISOString() : snapshot.syncedAt, "syncedAt");
140
+ const lastVerifiedAuthAt = arrayInput || !snapshot.lastVerifiedAuthAt
141
+ ? null
142
+ : isoTimestamp(snapshot.lastVerifiedAuthAt, "lastVerifiedAuthAt");
143
+ const actor = arrayInput || !snapshot.actor ? null : {
144
+ id: String(snapshot.actor.id || ""),
145
+ email: String(snapshot.actor.email || "").toLowerCase()
146
+ };
147
+ const entries = arrayInput ? snapshot : (snapshot.projects || snapshot.versions);
148
+ if (!Array.isArray(entries)) throw cliError("E_LOCAL_INDEX_SNAPSHOT", "snapshot.projects 배열이 필요합니다.");
149
+
150
+ const components = [];
151
+ const nodes = [];
152
+ const versionKeys = new Set();
153
+ const componentKeys = new Set();
154
+ for (const entry of entries) {
155
+ const project = entry?.project;
156
+ const projectId = nonEmptyString(project?.id, "project.id");
157
+ const projectSlug = nonEmptyString(project?.slug, "project.slug");
158
+ const versionNumber = finiteInteger(entry?.versionNumber ?? entry?.version?.version_number, "version_number", 1);
159
+ const componentSetRevision = finiteInteger(entry?.componentSet?.revision, "componentSet.revision", 0);
160
+ const componentChecksum = nonEmptyString(entry?.componentSet?.component_checksum ?? entry?.componentSet?.componentChecksum, "componentSet.component_checksum");
161
+ const versionKey = `${projectId}\u0000${versionNumber}`;
162
+ if (versionKeys.has(versionKey)) throw cliError("E_LOCAL_INDEX_SNAPSHOT", `중복 작품 버전입니다: ${projectSlug} v${versionNumber}`);
163
+ versionKeys.add(versionKey);
164
+ const rows = entry?.instances || entry?.componentRows;
165
+ if (!Array.isArray(rows)) throw cliError("E_LOCAL_INDEX_SNAPSHOT", `${projectSlug} v${versionNumber}의 Component 배열이 필요합니다.`);
166
+ for (const row of rows) {
167
+ const instanceId = nonEmptyString(row?.instanceId ?? row?.instance_id, "instance_id");
168
+ const templateId = nonEmptyString(row?.templateId ?? row?.template_id, "template_id");
169
+ const instanceRevision = finiteInteger(row?.revision, "instance.revision", 0);
170
+ assertJsonValue(row?.data);
171
+ const data = cloneJson(row.data);
172
+ const dataHash = sha256(data);
173
+ const componentKey = `${versionKey}\u0000${instanceId}`;
174
+ if (componentKeys.has(componentKey)) throw cliError("E_LOCAL_INDEX_SNAPSHOT", `중복 Component입니다: ${projectSlug} v${versionNumber} ${instanceId}`);
175
+ componentKeys.add(componentKey);
176
+ const component = {
177
+ projectId,
178
+ projectSlug,
179
+ versionNumber,
180
+ componentSetRevision,
181
+ componentChecksum,
182
+ instanceId,
183
+ templateId,
184
+ instanceRevision,
185
+ dataHash,
186
+ data
187
+ };
188
+ components.push(component);
189
+ for (const scalar of flattenJsonScalars(data)) {
190
+ const matchId = sha256({
191
+ projectId,
192
+ versionNumber,
193
+ instanceId,
194
+ instanceRevision,
195
+ dataHash,
196
+ pointer: scalar.pointer,
197
+ valueType: scalar.valueType,
198
+ valueJson: scalar.valueJson
199
+ });
200
+ nodes.push({ ...component, data: undefined, ...scalar, matchId });
201
+ }
202
+ }
203
+ }
204
+ const snapshotHash = sha256({ source, actor, syncedAt, lastVerifiedAuthAt, components });
205
+ const metadata = {
206
+ schemaVersion: INDEX_SCHEMA_VERSION,
207
+ source,
208
+ actor,
209
+ syncedAt,
210
+ lastVerifiedAuthAt,
211
+ snapshotHash,
212
+ projectVersionCount: versionKeys.size,
213
+ componentCount: components.length,
214
+ scalarCount: nodes.length
215
+ };
216
+ return { metadata, components, nodes };
217
+ }
218
+
219
+ module.exports = Object.freeze({
220
+ INDEX_SCHEMA_VERSION,
221
+ SERVER_SNAPSHOT_SOURCE,
222
+ own,
223
+ isObject,
224
+ finiteInteger,
225
+ assertJsonValue,
226
+ cloneJson,
227
+ escapeJsonPointerToken,
228
+ unescapeJsonPointerToken,
229
+ scalarType,
230
+ normalizedText,
231
+ foldedText,
232
+ flattenJsonScalars,
233
+ trigramValues,
234
+ normalizeSnapshot
235
+ });