@zeyos/client 0.3.0 → 0.4.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/CHANGELOG.md +16 -0
- package/README.md +31 -1
- package/agents/README.md +2 -0
- package/agents/shared/zeyos-entity-map.md +5 -1
- package/agents/shared/zeyos-entity-reference.md +89 -33
- package/docs/03-cli/02-commands.md +28 -0
- package/docs/06-okf/01-overview.md +70 -0
- package/docs/06-okf/02-producing-and-consuming.md +46 -0
- package/docs/06-okf/03-keeping-fresh.md +53 -0
- package/docs/06-okf/04-loops.md +58 -0
- package/docs/06-okf/_category_.json +9 -0
- package/okf/concepts/counting-and-sums.md +10 -0
- package/okf/concepts/dates-unix-seconds.md +12 -0
- package/okf/concepts/enums.md +14 -0
- package/okf/concepts/filters-vs-filter.md +14 -0
- package/okf/concepts/index.md +8 -0
- package/okf/concepts/operationid-vocabulary.md +17 -0
- package/okf/concepts/visibility-column.md +13 -0
- package/okf/entities/accounts.md +82 -0
- package/okf/entities/actionsteps.md +84 -0
- package/okf/entities/addresses.md +50 -0
- package/okf/entities/applicationassets.md +43 -0
- package/okf/entities/applications.md +62 -0
- package/okf/entities/appointments.md +79 -0
- package/okf/entities/associations.md +41 -0
- package/okf/entities/binfiles.md +32 -0
- package/okf/entities/campaigns.md +66 -0
- package/okf/entities/categories.md +55 -0
- package/okf/entities/channels.md +54 -0
- package/okf/entities/comments.md +44 -0
- package/okf/entities/components.md +46 -0
- package/okf/entities/contacts.md +96 -0
- package/okf/entities/contacts2contacts.md +42 -0
- package/okf/entities/contracts.md +83 -0
- package/okf/entities/couponcodes.md +58 -0
- package/okf/entities/coupons.md +69 -0
- package/okf/entities/customfields.md +59 -0
- package/okf/entities/davservers.md +74 -0
- package/okf/entities/devices.md +65 -0
- package/okf/entities/documents.md +76 -0
- package/okf/entities/dunning.md +82 -0
- package/okf/entities/dunning2transactions.md +46 -0
- package/okf/entities/entities2channels.md +42 -0
- package/okf/entities/events.md +57 -0
- package/okf/entities/feedservers.md +67 -0
- package/okf/entities/files.md +50 -0
- package/okf/entities/follows.md +40 -0
- package/okf/entities/forks.md +54 -0
- package/okf/entities/groups.md +48 -0
- package/okf/entities/groups2users.md +44 -0
- package/okf/entities/index.md +93 -0
- package/okf/entities/invitations.md +53 -0
- package/okf/entities/items.md +95 -0
- package/okf/entities/ledgers.md +56 -0
- package/okf/entities/likes.md +40 -0
- package/okf/entities/links.md +70 -0
- package/okf/entities/mailinglists.md +67 -0
- package/okf/entities/mailingrecipients.md +45 -0
- package/okf/entities/mailservers.md +77 -0
- package/okf/entities/messagereads.md +40 -0
- package/okf/entities/messages.md +104 -0
- package/okf/entities/notes.md +73 -0
- package/okf/entities/objects.md +70 -0
- package/okf/entities/opportunities.md +87 -0
- package/okf/entities/participants.md +52 -0
- package/okf/entities/payments.md +76 -0
- package/okf/entities/permissions.md +46 -0
- package/okf/entities/pricelists.md +70 -0
- package/okf/entities/pricelists2accounts.md +46 -0
- package/okf/entities/prices.md +49 -0
- package/okf/entities/projects.md +72 -0
- package/okf/entities/records.md +75 -0
- package/okf/entities/relateditems.md +43 -0
- package/okf/entities/resources.md +55 -0
- package/okf/entities/services.md +64 -0
- package/okf/entities/stocktransactions.md +72 -0
- package/okf/entities/storages.md +56 -0
- package/okf/entities/suppliers.md +51 -0
- package/okf/entities/tasks.md +86 -0
- package/okf/entities/tickets.md +86 -0
- package/okf/entities/transactions.md +118 -0
- package/okf/entities/users.md +66 -0
- package/okf/entities/weblets.md +66 -0
- package/okf/index.md +11 -0
- package/okf/log.md +4 -0
- package/okf/metrics/cash-received.md +10 -0
- package/okf/metrics/index.md +6 -0
- package/okf/metrics/invoiced-net-revenue.md +16 -0
- package/okf/metrics/open-customers.md +14 -0
- package/okf/metrics/overdue-receivables.md +12 -0
- package/okf/playbooks/customer-360.md +12 -0
- package/okf/playbooks/index.md +5 -0
- package/okf/playbooks/revenue-this-year.md +19 -0
- package/okf/playbooks/ticket-work-packet.md +11 -0
- package/package.json +9 -2
- package/scripts/data/okf-curation.mjs +258 -0
- package/scripts/generate-client.mjs +4 -275
- package/scripts/generate-okf.mjs +241 -0
- package/scripts/lib/okf.mjs +272 -0
- package/scripts/lib/spec-model.mjs +325 -0
- package/src/index.js +4 -0
- package/src/runtime/okf.js +237 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Shared OpenAPI / dbref parsing primitives.
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from scripts/generate-client.mjs so both the client codegen
|
|
4
|
+
// and the OKF producer (scripts/generate-okf.mjs) read the specs the same way.
|
|
5
|
+
// generate-client.mjs imports these unchanged; generate-okf.mjs additionally uses
|
|
6
|
+
// buildEntityModel() for the richer per-entity detail OKF docs need.
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
export const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace']);
|
|
11
|
+
|
|
12
|
+
export function unescapePointerToken(token) {
|
|
13
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function pointerGet(doc, ref) {
|
|
17
|
+
if (!ref.startsWith('#/')) {
|
|
18
|
+
throw new Error(`External $ref is not supported: ${ref}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parts = ref.slice(2).split('/').map(unescapePointerToken);
|
|
22
|
+
let current = doc;
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (current == null || !Object.prototype.hasOwnProperty.call(current, part)) {
|
|
25
|
+
throw new Error(`Unresolvable $ref: ${ref}`);
|
|
26
|
+
}
|
|
27
|
+
current = current[part];
|
|
28
|
+
}
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveRef(doc, value) {
|
|
33
|
+
if (!value || typeof value !== 'object') {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value.$ref === 'string') {
|
|
37
|
+
return pointerGet(doc, value.$ref);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeServer(server) {
|
|
43
|
+
const urlTemplate = server?.url || '';
|
|
44
|
+
const defaultVariables = {};
|
|
45
|
+
|
|
46
|
+
if (server?.variables && typeof server.variables === 'object') {
|
|
47
|
+
for (const [name, variable] of Object.entries(server.variables)) {
|
|
48
|
+
if (variable && typeof variable.default === 'string') {
|
|
49
|
+
defaultVariables[name] = variable.default;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const basePathTemplate = (() => {
|
|
55
|
+
const match = urlTemplate.match(/^https?:\/\/[^/]+(\/.*)$/i);
|
|
56
|
+
return match ? match[1] : '';
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
urlTemplate,
|
|
61
|
+
basePathTemplate,
|
|
62
|
+
defaultVariables
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeParameters(doc, pathItem, operation) {
|
|
67
|
+
const combined = [
|
|
68
|
+
...(Array.isArray(pathItem?.parameters) ? pathItem.parameters : []),
|
|
69
|
+
...(Array.isArray(operation?.parameters) ? operation.parameters : [])
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const indexed = new Map();
|
|
73
|
+
for (const rawParameter of combined) {
|
|
74
|
+
const parameter = resolveRef(doc, rawParameter);
|
|
75
|
+
if (!parameter || typeof parameter !== 'object') {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const key = `${parameter.in || 'unknown'}:${parameter.name || 'unknown'}`;
|
|
80
|
+
indexed.set(key, {
|
|
81
|
+
name: parameter.name,
|
|
82
|
+
in: parameter.in,
|
|
83
|
+
required: Boolean(parameter.required)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const normalized = Array.from(indexed.values()).filter((parameter) => parameter.name && parameter.in);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
all: normalized,
|
|
91
|
+
path: normalized.filter((parameter) => parameter.in === 'path').map((parameter) => parameter.name),
|
|
92
|
+
query: normalized.filter((parameter) => parameter.in === 'query').map((parameter) => parameter.name),
|
|
93
|
+
header: normalized.filter((parameter) => parameter.in === 'header').map((parameter) => parameter.name)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeRequestBody(doc, operation) {
|
|
98
|
+
const requestBody = resolveRef(doc, operation?.requestBody);
|
|
99
|
+
if (!requestBody || typeof requestBody !== 'object') {
|
|
100
|
+
return {
|
|
101
|
+
required: false,
|
|
102
|
+
contentTypes: []
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const content = requestBody.content && typeof requestBody.content === 'object' ? requestBody.content : {};
|
|
107
|
+
const contentTypes = Object.keys(content);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
required: Boolean(requestBody.required),
|
|
111
|
+
contentTypes
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeOperationSecurity(doc, operation) {
|
|
116
|
+
if (Array.isArray(operation?.security)) {
|
|
117
|
+
return operation.security;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(doc.security)) {
|
|
120
|
+
return doc.security;
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildOperationId({ operationId, method, route, existingIds }) {
|
|
126
|
+
if (operationId && !existingIds.has(operationId)) {
|
|
127
|
+
return operationId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const routeToken = route
|
|
131
|
+
.replace(/[^a-zA-Z0-9{}]/g, '_')
|
|
132
|
+
.replace(/[{}]/g, '')
|
|
133
|
+
.replace(/_+/g, '_')
|
|
134
|
+
.replace(/^_|_$/g, '');
|
|
135
|
+
|
|
136
|
+
let candidate = operationId || `${method.toLowerCase()}_${routeToken || 'operation'}`;
|
|
137
|
+
let suffix = 1;
|
|
138
|
+
|
|
139
|
+
while (existingIds.has(candidate)) {
|
|
140
|
+
suffix += 1;
|
|
141
|
+
candidate = `${operationId || `${method.toLowerCase()}_${routeToken || 'operation'}`}_${suffix}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function collectOperations(doc) {
|
|
148
|
+
const operations = [];
|
|
149
|
+
const existingIds = new Set();
|
|
150
|
+
|
|
151
|
+
for (const [route, pathItem] of Object.entries(doc.paths || {})) {
|
|
152
|
+
for (const [method, operation] of Object.entries(pathItem || {})) {
|
|
153
|
+
if (!HTTP_METHODS.has(method)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const normalizedMethod = method.toUpperCase();
|
|
158
|
+
const parameters = normalizeParameters(doc, pathItem, operation);
|
|
159
|
+
const requestBody = normalizeRequestBody(doc, operation);
|
|
160
|
+
const security = normalizeOperationSecurity(doc, operation);
|
|
161
|
+
const finalOperationId = buildOperationId({
|
|
162
|
+
operationId: operation.operationId,
|
|
163
|
+
method: normalizedMethod,
|
|
164
|
+
route,
|
|
165
|
+
existingIds
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
existingIds.add(finalOperationId);
|
|
169
|
+
|
|
170
|
+
operations.push({
|
|
171
|
+
operationId: finalOperationId,
|
|
172
|
+
summary: operation.summary || '',
|
|
173
|
+
deprecated: Boolean(operation.deprecated),
|
|
174
|
+
method: normalizedMethod,
|
|
175
|
+
path: route,
|
|
176
|
+
security,
|
|
177
|
+
requestBodyRequired: requestBody.required,
|
|
178
|
+
requestContentTypes: requestBody.contentTypes,
|
|
179
|
+
parameterNames: {
|
|
180
|
+
path: parameters.path,
|
|
181
|
+
query: parameters.query,
|
|
182
|
+
header: parameters.header
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
operations.sort((a, b) => {
|
|
189
|
+
if (a.path !== b.path) {
|
|
190
|
+
return a.path.localeCompare(b.path);
|
|
191
|
+
}
|
|
192
|
+
return a.method.localeCompare(b.method);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return operations;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildServices(specEntries) {
|
|
199
|
+
const services = {};
|
|
200
|
+
|
|
201
|
+
for (const specEntry of specEntries) {
|
|
202
|
+
const { service, file, doc } = specEntry;
|
|
203
|
+
const firstServer = Array.isArray(doc.servers) ? doc.servers[0] : undefined;
|
|
204
|
+
|
|
205
|
+
services[service] = {
|
|
206
|
+
key: service,
|
|
207
|
+
source: file,
|
|
208
|
+
title: doc.info?.title || '',
|
|
209
|
+
version: doc.info?.version || '',
|
|
210
|
+
server: normalizeServer(firstServer),
|
|
211
|
+
globalSecurity: Array.isArray(doc.security) ? doc.security : [],
|
|
212
|
+
operations: collectOperations(doc)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return services;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Enum values are documented inline in dbref descriptions as `N`=LABEL pairs,
|
|
220
|
+
// e.g. "Status (`0`=NOTSTARTED, `1`=AWAITINGACCEPTANCE, ...)". Extract them so
|
|
221
|
+
// the client can validate enum inputs and suggest valid values.
|
|
222
|
+
export function parseEnum(description) {
|
|
223
|
+
if (typeof description !== 'string') return null;
|
|
224
|
+
const out = {};
|
|
225
|
+
let count = 0;
|
|
226
|
+
const re = /`(-?\d+)`\s*=\s*([A-Za-z0-9_]+)/g;
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = re.exec(description)) !== null) {
|
|
229
|
+
out[match[1]] = match[2];
|
|
230
|
+
count += 1;
|
|
231
|
+
}
|
|
232
|
+
return count >= 2 ? out : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Compact field/enum/foreign-key map derived from openapi/dbref.json. Much
|
|
236
|
+
// smaller than the raw dbref (drops storage, collation, constraints, triggers,
|
|
237
|
+
// stats, etc.) so it ships cheaply and powers runtime introspection.
|
|
238
|
+
export function buildSchema(dbref) {
|
|
239
|
+
const schema = {};
|
|
240
|
+
if (!Array.isArray(dbref)) return schema;
|
|
241
|
+
|
|
242
|
+
for (const entity of dbref) {
|
|
243
|
+
if (!entity || typeof entity !== 'object' || !entity.name || !Array.isArray(entity.fields)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fields = {};
|
|
248
|
+
for (const field of entity.fields) {
|
|
249
|
+
if (!field || typeof field !== 'object' || !field.name) continue;
|
|
250
|
+
const def = { type: field.type || 'unknown' };
|
|
251
|
+
if (field.indexed) def.indexed = true;
|
|
252
|
+
if (Array.isArray(field.fkeys) && field.fkeys.length > 0 && field.fkeys[0].table) {
|
|
253
|
+
def.fk = field.fkeys[0].table;
|
|
254
|
+
}
|
|
255
|
+
const enumValues = parseEnum(field.description);
|
|
256
|
+
if (enumValues) def.enum = enumValues;
|
|
257
|
+
fields[field.name] = def;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
schema[entity.name] = { type: entity.type || 'table', fields };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return schema;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Richer per-entity model for OKF docs. Unlike buildSchema (compact, for the
|
|
267
|
+
// shipped client), this preserves nullability, defaults, FK target field, the
|
|
268
|
+
// raw field description, and the entity's indexes — including the GIN/partial
|
|
269
|
+
// definitions behind the `filters`-vs-`filter` foreign-key footgun.
|
|
270
|
+
export function buildEntityModel(dbref) {
|
|
271
|
+
const model = {};
|
|
272
|
+
if (!Array.isArray(dbref)) return model;
|
|
273
|
+
|
|
274
|
+
for (const entity of dbref) {
|
|
275
|
+
if (!entity || typeof entity !== 'object' || !entity.name || !Array.isArray(entity.fields)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const fields = entity.fields
|
|
280
|
+
.filter((field) => field && typeof field === 'object' && field.name)
|
|
281
|
+
.map((field) => {
|
|
282
|
+
const fk = Array.isArray(field.fkeys) && field.fkeys.length > 0 && field.fkeys[0].table
|
|
283
|
+
? { table: field.fkeys[0].table, field: field.fkeys[0].field || 'ID' }
|
|
284
|
+
: null;
|
|
285
|
+
return {
|
|
286
|
+
name: field.name,
|
|
287
|
+
type: field.type || 'unknown',
|
|
288
|
+
notnull: Boolean(field.notnull),
|
|
289
|
+
default: field.default ?? null,
|
|
290
|
+
indexed: Boolean(field.indexed),
|
|
291
|
+
fk,
|
|
292
|
+
enum: parseEnum(field.description),
|
|
293
|
+
description: typeof field.description === 'string' ? field.description : ''
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const indexes = (Array.isArray(entity.indexes) ? entity.indexes : []).map((index) => ({
|
|
298
|
+
name: index.name,
|
|
299
|
+
method: index.method || 'btree',
|
|
300
|
+
unique: Boolean(index.unique),
|
|
301
|
+
primary: Boolean(index.primary),
|
|
302
|
+
partial: Boolean(index.partial),
|
|
303
|
+
keys: Array.isArray(index.keys) ? index.keys : [],
|
|
304
|
+
def: typeof index.def === 'string' ? index.def : ''
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
model[entity.name] = { name: entity.name, type: entity.type || 'table', fields, indexes };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return model;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function loadSpec(absolutePath) {
|
|
314
|
+
const raw = await readFile(absolutePath, 'utf8');
|
|
315
|
+
return JSON.parse(raw);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function loadOptionalSpec(absolutePath) {
|
|
319
|
+
try {
|
|
320
|
+
return await loadSpec(absolutePath);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error?.code === 'ENOENT') return null;
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createZeyosClient } from './runtime/client.js';
|
|
2
2
|
import { ZeyosApiError, ZeyosValidationError } from './runtime/error.js';
|
|
3
3
|
import { MemoryTokenStore, normalizeTokenSet, tokenResponseToTokenSet } from './runtime/token-store.js';
|
|
4
|
+
import {
|
|
5
|
+
OKF_VERSION, buildOkf, loadOkfBundle, validateOkfBundle, validateOkfFiles, conceptIdForResource
|
|
6
|
+
} from './runtime/okf.js';
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* @typedef {null|boolean|number|string|JsonValue[]|Record<string, JsonValue>} JsonValue
|
|
@@ -83,3 +86,4 @@ export function normalizeCountResult(result) {
|
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
export { createZeyosClient, ZeyosApiError, ZeyosValidationError, MemoryTokenStore, normalizeTokenSet, tokenResponseToTokenSet };
|
|
89
|
+
export { OKF_VERSION, buildOkf, loadOkfBundle, validateOkfBundle, validateOkfFiles, conceptIdForResource };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Open Knowledge Format (OKF v0.1) support for @zeyos/client.
|
|
2
|
+
//
|
|
3
|
+
// Two halves:
|
|
4
|
+
// • Pure (browser + Node): the OKF conformance constants, a tolerant concept
|
|
5
|
+
// parser, a bundle validator, and buildOkf() — which synthesizes a conformant
|
|
6
|
+
// OKF bundle from the client's own introspection surface (the generated
|
|
7
|
+
// SCHEMA + SERVICES). No filesystem access.
|
|
8
|
+
// • Node-only: loadOkfBundle() reads the shipped okf/ bundle (or any directory)
|
|
9
|
+
// from disk via a lazy import, so this module stays bundler/browser-safe.
|
|
10
|
+
//
|
|
11
|
+
// The richer build-time producer (scripts/generate-okf.mjs) emits the curated,
|
|
12
|
+
// managed-block bundle under okf/. buildOkf() here is the lightweight runtime
|
|
13
|
+
// projection of what the shipped client knows — handy for emitting OKF in
|
|
14
|
+
// environments without the bundled files, or to diff against a live instance.
|
|
15
|
+
|
|
16
|
+
import { SCHEMA } from '../generated/schema.js';
|
|
17
|
+
import { SERVICES } from '../generated/operations.js';
|
|
18
|
+
|
|
19
|
+
export const OKF_VERSION = '0.1';
|
|
20
|
+
|
|
21
|
+
// Markers fencing producer-generated content inside a concept's body. Exported so
|
|
22
|
+
// the build-time renderer (scripts/lib/okf.mjs) shares one definition.
|
|
23
|
+
export const GENERATED_START = '<!-- okf:generated:start — rewritten by scripts/generate-okf.mjs; do not edit by hand -->';
|
|
24
|
+
export const GENERATED_END = '<!-- okf:generated:end -->';
|
|
25
|
+
export const GENERATED_FRONTMATTER_KEYS = Object.freeze([
|
|
26
|
+
'type', 'title', 'description', 'resource', 'tags', 'timestamp',
|
|
27
|
+
'api_backed', 'list_operation', 'visibility_column'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Files that are not concept documents (spec §5/§6).
|
|
31
|
+
const RESERVED_BASENAMES = new Set(['index.md', 'log.md']);
|
|
32
|
+
|
|
33
|
+
const VERB_RE = /^(list|get|create|update|delete|exists)/;
|
|
34
|
+
|
|
35
|
+
// ── Parsing / validation (pure) ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Tolerant frontmatter + body split. Returns `{ frontmatter, body }` where
|
|
38
|
+
* frontmatter is a flat string→string map (lists kept as their raw text). */
|
|
39
|
+
export function parseConcept(content) {
|
|
40
|
+
const text = String(content || '');
|
|
41
|
+
const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
42
|
+
if (!match) return { frontmatter: {}, body: text };
|
|
43
|
+
const frontmatter = {};
|
|
44
|
+
for (const line of match[1].split('\n')) {
|
|
45
|
+
const kv = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
|
46
|
+
if (kv) frontmatter[kv[1]] = kv[2].trim();
|
|
47
|
+
}
|
|
48
|
+
return { frontmatter, body: text.slice(match[0].length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isReserved(relPath) {
|
|
52
|
+
const base = relPath.split('/').pop();
|
|
53
|
+
return RESERVED_BASENAMES.has(base);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate an OKF bundle for v0.1 conformance (spec §9): every non-reserved `.md`
|
|
58
|
+
* file must have parseable frontmatter with a non-empty `type`. Tolerant by
|
|
59
|
+
* design — unknown types/keys and broken links are NOT errors.
|
|
60
|
+
*
|
|
61
|
+
* @param {Record<string,string>} files - relativePath → file content.
|
|
62
|
+
* @returns {{ valid: boolean, errors: { path: string, message: string }[], conceptCount: number }}
|
|
63
|
+
*/
|
|
64
|
+
export function validateOkfFiles(files) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
let conceptCount = 0;
|
|
67
|
+
for (const [relPath, content] of Object.entries(files || {})) {
|
|
68
|
+
if (!relPath.endsWith('.md') || isReserved(relPath)) continue;
|
|
69
|
+
conceptCount += 1;
|
|
70
|
+
const { frontmatter } = parseConcept(content);
|
|
71
|
+
if (!Object.keys(frontmatter).length) {
|
|
72
|
+
errors.push({ path: relPath, message: 'Missing YAML frontmatter.' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!frontmatter.type) {
|
|
76
|
+
errors.push({ path: relPath, message: 'Frontmatter is missing the required `type` field.' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { valid: errors.length === 0, errors, conceptCount };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The OKF concept ID for a ZeyOS resource, e.g. `tickets` → `entities/tickets`. */
|
|
83
|
+
export function conceptIdForResource(resource) {
|
|
84
|
+
return `entities/${resource}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── buildOkf: synthesize a bundle from the client's introspection surface ───────
|
|
88
|
+
|
|
89
|
+
function groupOperations(services) {
|
|
90
|
+
const byResource = new Map();
|
|
91
|
+
for (const service of Object.values(services || {})) {
|
|
92
|
+
for (const op of service.operations || []) {
|
|
93
|
+
const resource = resourceFromPath(op.path);
|
|
94
|
+
if (!resource) continue;
|
|
95
|
+
if (!byResource.has(resource)) byResource.set(resource, {});
|
|
96
|
+
const bucket = byResource.get(resource);
|
|
97
|
+
const m = VERB_RE.exec(op.operationId);
|
|
98
|
+
const key = m ? m[1] : op.operationId;
|
|
99
|
+
if (!bucket[key]) bucket[key] = op.operationId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return byResource;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resourceFromPath(p) {
|
|
106
|
+
if (typeof p !== 'string') return null;
|
|
107
|
+
for (const segment of p.split('/')) {
|
|
108
|
+
if (segment && !segment.startsWith('{')) return segment;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function titleFromOps(ops, fallback) {
|
|
114
|
+
const op = ops.list || ops.get;
|
|
115
|
+
if (!op) return fallback.charAt(0).toUpperCase() + fallback.slice(1);
|
|
116
|
+
return op.replace(VERB_RE, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim() || fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Deterministic, dependency-free 32-bit hash for the bundle source snapshot.
|
|
120
|
+
function fnv1a(str) {
|
|
121
|
+
let h = 0x811c9dc5;
|
|
122
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
123
|
+
h ^= str.charCodeAt(i);
|
|
124
|
+
h = Math.imul(h, 0x01000193);
|
|
125
|
+
}
|
|
126
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderEntityDoc(name, entry, ops, hasDoc) {
|
|
130
|
+
const link = (target) => (hasDoc.has(target) ? `/entities/${target}.md` : null);
|
|
131
|
+
const fields = Object.entries(entry.fields || {});
|
|
132
|
+
|
|
133
|
+
const schemaRows = fields.map(([fname, def]) => {
|
|
134
|
+
const fkCell = def.fk ? (link(def.fk) ? `[${def.fk}](${link(def.fk)})` : def.fk) : '—';
|
|
135
|
+
const enumCell = def.enum ? Object.entries(def.enum).map(([k, v]) => `${k}=${v}`).join(', ') : '—';
|
|
136
|
+
return `| \`${fname}\` | ${def.type || 'unknown'} | ${def.indexed ? 'yes' : '—'} | ${fkCell} | ${enumCell} |`;
|
|
137
|
+
});
|
|
138
|
+
const schema = `# Schema\n\n| Column | Type | Indexed | FK | Enum |\n|---|---|---|---|---|\n${schemaRows.join('\n')}`;
|
|
139
|
+
|
|
140
|
+
const fks = fields.filter(([, d]) => d.fk);
|
|
141
|
+
const fkSection = fks.length
|
|
142
|
+
? `\n\n# Foreign Keys\n\n${fks.map(([f, d]) => `- \`${f}\` → ${link(d.fk) ? `[${d.fk}](${link(d.fk)})` : d.fk}`).join('\n')}`
|
|
143
|
+
: '';
|
|
144
|
+
|
|
145
|
+
const order = ['list', 'get', 'create', 'update', 'delete', 'exists'];
|
|
146
|
+
const opLines = order.filter((k) => ops[k]).map((k) => `- ${k}: \`${ops[k]}\``);
|
|
147
|
+
const opSection = opLines.length ? `\n\n# Operations\n\n${opLines.join('\n')}` : '';
|
|
148
|
+
|
|
149
|
+
const title = titleFromOps(ops, name);
|
|
150
|
+
const fm = [
|
|
151
|
+
'type: ZeyOS Entity',
|
|
152
|
+
`title: ${title}`,
|
|
153
|
+
`resource: zeyos://api/${name}`,
|
|
154
|
+
'tags: [generated]',
|
|
155
|
+
'api_backed: true'
|
|
156
|
+
];
|
|
157
|
+
if (ops.list) fm.push(`list_operation: ${ops.list}`);
|
|
158
|
+
fm.push(`visibility_column: ${Object.prototype.hasOwnProperty.call(entry.fields || {}, 'visibility')}`);
|
|
159
|
+
|
|
160
|
+
return `---\n${fm.join('\n')}\n---\n\n${schema}${fkSection}${opSection}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Synthesize a conformant OKF v0.1 bundle from a client schema + services. Pure:
|
|
165
|
+
* returns a `{ relativePath: content }` map; the caller decides whether to write
|
|
166
|
+
* it to disk. Defaults to the generated SCHEMA/SERVICES baked into the client.
|
|
167
|
+
*
|
|
168
|
+
* @param {{ schema?: object, services?: object }} [input]
|
|
169
|
+
* @returns {Record<string,string>}
|
|
170
|
+
*/
|
|
171
|
+
export function buildOkf({ schema = SCHEMA, services = SERVICES } = {}) {
|
|
172
|
+
const ops = groupOperations(services);
|
|
173
|
+
const resources = Object.keys(schema).filter((r) => ops.has(r)).sort();
|
|
174
|
+
const hasDoc = new Set(resources);
|
|
175
|
+
const files = {};
|
|
176
|
+
|
|
177
|
+
for (const name of resources) {
|
|
178
|
+
files[`entities/${name}.md`] = renderEntityDoc(name, schema[name], ops.get(name) || {}, hasDoc);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const indexItems = resources
|
|
182
|
+
.map((name) => `* [${titleFromOps(ops.get(name) || {}, name)}](${name}.md)`)
|
|
183
|
+
.join('\n');
|
|
184
|
+
files['entities/index.md'] = `# Entities\n\n${indexItems}\n`;
|
|
185
|
+
|
|
186
|
+
const signature = resources.map((r) => `${r}:${Object.keys(schema[r].fields || {}).join(',')}`).join('|');
|
|
187
|
+
files['index.md'] = `---\nokf_version: ${OKF_VERSION}\nsource_snapshot: ${fnv1a(signature)}\n---\n\n# ZeyOS Knowledge Bundle\n\n* [Entities](entities/) - ${resources.length} API-backed entity concepts.\n`;
|
|
188
|
+
|
|
189
|
+
return files;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Node-only loaders (lazy fs so the module stays browser-safe) ────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read an OKF bundle directory from disk. Node only.
|
|
196
|
+
* @param {string} dir - Path to a bundle root (e.g. the shipped okf/).
|
|
197
|
+
* @returns {Promise<{ version: string|null, files: Record<string,string>, concepts: Record<string,{frontmatter:object,body:string}> }>}
|
|
198
|
+
*/
|
|
199
|
+
export async function loadOkfBundle(dir) {
|
|
200
|
+
const { readFile, readdir } = await import('node:fs/promises');
|
|
201
|
+
const path = await import('node:path');
|
|
202
|
+
|
|
203
|
+
const files = {};
|
|
204
|
+
async function walk(current, prefix) {
|
|
205
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const abs = path.join(current, entry.name);
|
|
208
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
209
|
+
if (entry.isDirectory()) await walk(abs, rel);
|
|
210
|
+
else if (entry.name.endsWith('.md')) files[rel] = await readFile(abs, 'utf8');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
await walk(dir, '');
|
|
214
|
+
|
|
215
|
+
const concepts = {};
|
|
216
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
217
|
+
if (isReserved(rel)) continue;
|
|
218
|
+
concepts[rel.replace(/\.md$/, '')] = parseConcept(content);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let version = null;
|
|
222
|
+
if (files['index.md']) version = parseConcept(files['index.md']).frontmatter.okf_version || null;
|
|
223
|
+
|
|
224
|
+
return { version, files, concepts };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate an OKF bundle. Accepts a directory path (Node) or an in-memory
|
|
229
|
+
* `{ path: content }` map (universal). Returns the validateOkfFiles() result.
|
|
230
|
+
*/
|
|
231
|
+
export async function validateOkfBundle(dirOrFiles) {
|
|
232
|
+
if (typeof dirOrFiles === 'string') {
|
|
233
|
+
const { files } = await loadOkfBundle(dirOrFiles);
|
|
234
|
+
return validateOkfFiles(files);
|
|
235
|
+
}
|
|
236
|
+
return validateOkfFiles(dirOrFiles);
|
|
237
|
+
}
|