@quatrain/okf 1.0.1
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 +17 -0
- package/dist/OKFBackendAdapter.d.ts +80 -0
- package/dist/OKFBackendAdapter.js +297 -0
- package/dist/OKFBackendAdapter.test.d.ts +1 -0
- package/dist/OKFBackendAdapter.test.js +128 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -0
- package/package.json +38 -0
- package/src/OKFBackendAdapter.test.ts +100 -0
- package/src/OKFBackendAdapter.ts +271 -0
- package/src/index.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @quatrain/okf
|
|
2
|
+
|
|
3
|
+
Open Knowledge Format (OKF) flat file storage adapter for the Quatrain Core framework.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The `@quatrain/okf` package provides a file-based persistence adapter (`OKFBackendAdapter`) that serializes Quatrain `PersistedBaseObject` entities into a structured flat file filesystem directory conforming to the OKF format.
|
|
8
|
+
|
|
9
|
+
This adapter is specifically designed to facilitate local-first, offline-first architectures by excluding relational databases and utilizing structured directory trees containing lightweight JSON files.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Decoupled Architecture:** Pure filesystem storage format independent of underlying Git versioning or synchronization layers.
|
|
14
|
+
- **Operator Auditing:** Automatically stores the operator's email in the document's metadata block (`meta.created_by`) for full change traceability.
|
|
15
|
+
- **Hierarchical Layouts:**
|
|
16
|
+
- Telemetry: Saved as `telemetry/YYYY-MM-DD/{type}/{HHMMSS}-{millis}-{bassinId}.json`
|
|
17
|
+
- Other: Saved as `{collection}/{uid}.json`
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AbstractBackendAdapter, BackendParameters, DataObjectClass, QueryResultType, Filters, Filter, SortAndLimit } from '@quatrain/backend';
|
|
2
|
+
/**
|
|
3
|
+
* File-based persistence adapter conforming to the Open Knowledge Format (OKF) standard.
|
|
4
|
+
* Serializes entities into flat directory layouts.
|
|
5
|
+
*/
|
|
6
|
+
export declare class OKFBackendAdapter extends AbstractBackendAdapter {
|
|
7
|
+
protected dataDir: string;
|
|
8
|
+
/**
|
|
9
|
+
* Instantiates the OKF storage adapter.
|
|
10
|
+
*
|
|
11
|
+
* @param params - Configuration parameters containing database path and optional settings.
|
|
12
|
+
*/
|
|
13
|
+
constructor(params?: BackendParameters);
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the relative storage path matching the OKF collection and metadata hierarchy.
|
|
16
|
+
* For telemetry: telemetry/YYYY-MM-DD/{type}/{HHMMSS}-{millis}-{bassinId}.json
|
|
17
|
+
* For other: {collection}/{uid}.json
|
|
18
|
+
*
|
|
19
|
+
* @param dao - The data object model being persisted.
|
|
20
|
+
* @param uid - The unique identifier of the record.
|
|
21
|
+
* @returns The relative file path string.
|
|
22
|
+
*/
|
|
23
|
+
getOKFPath(dao: DataObjectClass<any>, uid: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Creates a new JSON document on disk matching the OKF structure.
|
|
26
|
+
*
|
|
27
|
+
* @param dao - The payload object.
|
|
28
|
+
* @param desiredUid - Optional custom UID.
|
|
29
|
+
* @returns The hydrated DataObject.
|
|
30
|
+
*/
|
|
31
|
+
create(dao: DataObjectClass<any>, desiredUid?: string): Promise<DataObjectClass<any>>;
|
|
32
|
+
/**
|
|
33
|
+
* Hydrates a DataObject by reading its matching flat file on disk.
|
|
34
|
+
*
|
|
35
|
+
* @param dao - The target skeleton DataObject.
|
|
36
|
+
* @returns The populated DataObject.
|
|
37
|
+
*/
|
|
38
|
+
read(dao: DataObjectClass<any>): Promise<DataObjectClass<any>>;
|
|
39
|
+
/**
|
|
40
|
+
* Overwrites the JSON document with the updated payload.
|
|
41
|
+
*
|
|
42
|
+
* @param dao - The modified payload.
|
|
43
|
+
* @returns The DataObject instance.
|
|
44
|
+
*/
|
|
45
|
+
update(dao: DataObjectClass<any>): Promise<DataObjectClass<any>>;
|
|
46
|
+
/**
|
|
47
|
+
* Deletes the JSON file from the filesystem.
|
|
48
|
+
*
|
|
49
|
+
* @param dao - The target DataObject to remove.
|
|
50
|
+
* @returns The cleared DataObject.
|
|
51
|
+
*/
|
|
52
|
+
delete(dao: DataObjectClass<any>): Promise<DataObjectClass<any>>;
|
|
53
|
+
/**
|
|
54
|
+
* Recursively removes the directory associated with a collection.
|
|
55
|
+
*
|
|
56
|
+
* @param collection - The collection folder path to clear.
|
|
57
|
+
*/
|
|
58
|
+
deleteCollection(collection: string): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Helper to flatten filters into a simple array.
|
|
61
|
+
*/
|
|
62
|
+
protected _flattenFilters(f?: Filters | Filter[]): Filter[];
|
|
63
|
+
/**
|
|
64
|
+
* Queries the local files by scanning directories and evaluating conditions in-memory.
|
|
65
|
+
*
|
|
66
|
+
* @param dataObject - Base template representing the target collection.
|
|
67
|
+
* @param filters - Active constraints.
|
|
68
|
+
* @param pagination - Limits and sorting directives.
|
|
69
|
+
* @returns QueryResult containing found items and diagnostics.
|
|
70
|
+
*/
|
|
71
|
+
find(dataObject: DataObjectClass<any>, filters?: Filters | Filter[], pagination?: SortAndLimit): Promise<QueryResultType<any>>;
|
|
72
|
+
generateCreateSql(): {
|
|
73
|
+
upSql: string;
|
|
74
|
+
downSql: string;
|
|
75
|
+
};
|
|
76
|
+
generateDeltaSql(): {
|
|
77
|
+
upSql: never[];
|
|
78
|
+
downSql: never[];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.OKFBackendAdapter = void 0;
|
|
46
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
48
|
+
const crypto = __importStar(require("node:crypto"));
|
|
49
|
+
const backend_1 = require("@quatrain/backend");
|
|
50
|
+
const core_1 = require("@quatrain/core");
|
|
51
|
+
/**
|
|
52
|
+
* File-based persistence adapter conforming to the Open Knowledge Format (OKF) standard.
|
|
53
|
+
* Serializes entities into flat directory layouts.
|
|
54
|
+
*/
|
|
55
|
+
class OKFBackendAdapter extends backend_1.AbstractBackendAdapter {
|
|
56
|
+
/**
|
|
57
|
+
* Instantiates the OKF storage adapter.
|
|
58
|
+
*
|
|
59
|
+
* @param params - Configuration parameters containing database path and optional settings.
|
|
60
|
+
*/
|
|
61
|
+
constructor(params) {
|
|
62
|
+
var _a;
|
|
63
|
+
const actualParams = params || {};
|
|
64
|
+
super(actualParams);
|
|
65
|
+
this.dataDir = ((_a = actualParams.config) === null || _a === void 0 ? void 0 : _a.database) || process.env.STUDIO_DATA_DIR || '/data/okf';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the relative storage path matching the OKF collection and metadata hierarchy.
|
|
69
|
+
* For telemetry: telemetry/YYYY-MM-DD/{type}/{HHMMSS}-{millis}-{bassinId}.json
|
|
70
|
+
* For other: {collection}/{uid}.json
|
|
71
|
+
*
|
|
72
|
+
* @param dao - The data object model being persisted.
|
|
73
|
+
* @param uid - The unique identifier of the record.
|
|
74
|
+
* @returns The relative file path string.
|
|
75
|
+
*/
|
|
76
|
+
getOKFPath(dao, uid) {
|
|
77
|
+
const collection = this.getCollection(dao);
|
|
78
|
+
if (!collection) {
|
|
79
|
+
throw new backend_1.BackendError("[OKF] Cannot resolve path without collection name");
|
|
80
|
+
}
|
|
81
|
+
if (collection === 'telemetry') {
|
|
82
|
+
const createdAt = dao.val('createdAt') || new Date().toISOString();
|
|
83
|
+
const dateObj = new Date(createdAt);
|
|
84
|
+
const yyyymmdd = dateObj.toISOString().split('T')[0];
|
|
85
|
+
const timePart = dateObj.toISOString().split('T')[1].replace(/Z/g, '');
|
|
86
|
+
const parts = timePart.split('.');
|
|
87
|
+
const hms = parts[0];
|
|
88
|
+
const ms = parts[1];
|
|
89
|
+
const hhmmsstrim = hms.replace(/:/g, '');
|
|
90
|
+
const millis = ms ? ms.substring(0, 3) : '000';
|
|
91
|
+
const type = dao.val('type') || 'o2';
|
|
92
|
+
const bassinId = dao.val('bassin') || 'unknown';
|
|
93
|
+
return path.join('telemetry', yyyymmdd, type, `${hhmmsstrim}-${millis}-${bassinId}.json`);
|
|
94
|
+
}
|
|
95
|
+
return path.join(collection, `${uid}.json`);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new JSON document on disk matching the OKF structure.
|
|
99
|
+
*
|
|
100
|
+
* @param dao - The payload object.
|
|
101
|
+
* @param desiredUid - Optional custom UID.
|
|
102
|
+
* @returns The hydrated DataObject.
|
|
103
|
+
*/
|
|
104
|
+
create(dao, desiredUid) {
|
|
105
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
const uid = desiredUid || dao.val('id') || crypto.randomUUID();
|
|
107
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
108
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
109
|
+
const payload = {
|
|
110
|
+
meta: {
|
|
111
|
+
version: '1.0',
|
|
112
|
+
created_by: dao.val('createdBy') || 'operator@sodav.ci',
|
|
113
|
+
created_at: dao.val('createdAt') || new Date().toISOString()
|
|
114
|
+
},
|
|
115
|
+
data: dao.toJSON()
|
|
116
|
+
};
|
|
117
|
+
yield fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
118
|
+
yield fs.writeFile(fullPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
119
|
+
dao.uri = new core_1.ObjectUri(`${this.getCollection(dao)}/${uid}`);
|
|
120
|
+
return dao;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Hydrates a DataObject by reading its matching flat file on disk.
|
|
125
|
+
*
|
|
126
|
+
* @param dao - The target skeleton DataObject.
|
|
127
|
+
* @returns The populated DataObject.
|
|
128
|
+
*/
|
|
129
|
+
read(dao) {
|
|
130
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
131
|
+
const uid = dao.uri.uid;
|
|
132
|
+
if (!uid) {
|
|
133
|
+
throw new backend_1.BackendError("[OKF] Cannot read record without a valid UID");
|
|
134
|
+
}
|
|
135
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
136
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
137
|
+
try {
|
|
138
|
+
const raw = yield fs.readFile(fullPath, 'utf-8');
|
|
139
|
+
const parsed = JSON.parse(raw);
|
|
140
|
+
return yield dao.populate(parsed.data);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw new core_1.NotFoundError(`[OKF] Record not found at path: ${relativePath}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Overwrites the JSON document with the updated payload.
|
|
149
|
+
*
|
|
150
|
+
* @param dao - The modified payload.
|
|
151
|
+
* @returns The DataObject instance.
|
|
152
|
+
*/
|
|
153
|
+
update(dao) {
|
|
154
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
155
|
+
const uid = dao.uri.uid;
|
|
156
|
+
if (!uid) {
|
|
157
|
+
throw new backend_1.BackendError("[OKF] Cannot update record without a valid UID");
|
|
158
|
+
}
|
|
159
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
160
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
161
|
+
const payload = {
|
|
162
|
+
meta: {
|
|
163
|
+
version: '1.0',
|
|
164
|
+
created_by: dao.val('createdBy') || 'operator@sodav.ci',
|
|
165
|
+
created_at: dao.val('createdAt') || new Date().toISOString()
|
|
166
|
+
},
|
|
167
|
+
data: dao.toJSON()
|
|
168
|
+
};
|
|
169
|
+
yield fs.writeFile(fullPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
170
|
+
return dao;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Deletes the JSON file from the filesystem.
|
|
175
|
+
*
|
|
176
|
+
* @param dao - The target DataObject to remove.
|
|
177
|
+
* @returns The cleared DataObject.
|
|
178
|
+
*/
|
|
179
|
+
delete(dao) {
|
|
180
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
181
|
+
const uid = dao.uri.uid;
|
|
182
|
+
if (!uid) {
|
|
183
|
+
throw new backend_1.BackendError("[OKF] Cannot delete record without a valid UID");
|
|
184
|
+
}
|
|
185
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
186
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
187
|
+
try {
|
|
188
|
+
yield fs.unlink(fullPath);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
throw new core_1.NotFoundError(`[OKF] Failed to delete record at path: ${relativePath}`);
|
|
192
|
+
}
|
|
193
|
+
return dao;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Recursively removes the directory associated with a collection.
|
|
198
|
+
*
|
|
199
|
+
* @param collection - The collection folder path to clear.
|
|
200
|
+
*/
|
|
201
|
+
deleteCollection(collection) {
|
|
202
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
203
|
+
const colPath = path.join(this.dataDir, collection);
|
|
204
|
+
yield fs.rm(colPath, { recursive: true, force: true });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Helper to flatten filters into a simple array.
|
|
209
|
+
*/
|
|
210
|
+
_flattenFilters(f) {
|
|
211
|
+
if (!f)
|
|
212
|
+
return [];
|
|
213
|
+
if (Array.isArray(f))
|
|
214
|
+
return f;
|
|
215
|
+
const list = [];
|
|
216
|
+
if (f.and)
|
|
217
|
+
list.push(...f.and);
|
|
218
|
+
if (f.or)
|
|
219
|
+
list.push(...f.or);
|
|
220
|
+
return list;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Queries the local files by scanning directories and evaluating conditions in-memory.
|
|
224
|
+
*
|
|
225
|
+
* @param dataObject - Base template representing the target collection.
|
|
226
|
+
* @param filters - Active constraints.
|
|
227
|
+
* @param pagination - Limits and sorting directives.
|
|
228
|
+
* @returns QueryResult containing found items and diagnostics.
|
|
229
|
+
*/
|
|
230
|
+
find(dataObject, filters, pagination) {
|
|
231
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
232
|
+
const collection = this.getCollection(dataObject);
|
|
233
|
+
if (!collection) {
|
|
234
|
+
throw new backend_1.BackendError("[OKF] Cannot query without a valid collection name");
|
|
235
|
+
}
|
|
236
|
+
const items = [];
|
|
237
|
+
const baseColPath = path.join(this.dataDir, collection);
|
|
238
|
+
const scanDir = (dir) => __awaiter(this, void 0, void 0, function* () {
|
|
239
|
+
try {
|
|
240
|
+
const entries = yield fs.readdir(dir, { withFileTypes: true });
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
const fullPath = path.join(dir, entry.name);
|
|
243
|
+
if (entry.isDirectory()) {
|
|
244
|
+
yield scanDir(fullPath);
|
|
245
|
+
}
|
|
246
|
+
else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
247
|
+
const content = yield fs.readFile(fullPath, 'utf-8');
|
|
248
|
+
const parsed = JSON.parse(content);
|
|
249
|
+
const dao = yield dataObject.clone(parsed.data);
|
|
250
|
+
let keep = true;
|
|
251
|
+
if (filters) {
|
|
252
|
+
const filterArr = this._flattenFilters(filters);
|
|
253
|
+
for (const filter of filterArr) {
|
|
254
|
+
const prop = dao.get(filter.prop);
|
|
255
|
+
if (typeof prop === 'undefined') {
|
|
256
|
+
keep = false;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
if (prop.val() !== filter.value) {
|
|
260
|
+
keep = false;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (keep) {
|
|
266
|
+
items.push(dao);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// Ignore missing folders
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
yield scanDir(baseColPath);
|
|
276
|
+
// Simple slice implementation for limit/offset
|
|
277
|
+
let slicedItems = items;
|
|
278
|
+
if (pagination && pagination.limits) {
|
|
279
|
+
const offset = pagination.limits.offset || 0;
|
|
280
|
+
const batch = pagination.limits.batch || items.length;
|
|
281
|
+
slicedItems = items.slice(offset, offset + batch);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
items: slicedItems,
|
|
285
|
+
meta: {
|
|
286
|
+
count: items.length,
|
|
287
|
+
offset: (pagination === null || pagination === void 0 ? void 0 : pagination.limits.offset) || 0,
|
|
288
|
+
batch: (pagination === null || pagination === void 0 ? void 0 : pagination.limits.batch) || items.length,
|
|
289
|
+
executionTime: Date.now()
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
generateCreateSql() { return { upSql: '', downSql: '' }; }
|
|
295
|
+
generateDeltaSql() { return { upSql: [], downSql: [] }; }
|
|
296
|
+
}
|
|
297
|
+
exports.OKFBackendAdapter = OKFBackendAdapter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
46
|
+
const path = __importStar(require("node:path"));
|
|
47
|
+
const OKFBackendAdapter_1 = require("./OKFBackendAdapter");
|
|
48
|
+
const backend_1 = require("@quatrain/backend");
|
|
49
|
+
const backend_2 = require("@quatrain/backend");
|
|
50
|
+
const core_1 = require("@quatrain/core");
|
|
51
|
+
class OKFTestObject extends backend_2.PersistedBaseObject {
|
|
52
|
+
}
|
|
53
|
+
OKFTestObject.COLLECTION = 'test_collection';
|
|
54
|
+
OKFTestObject.PROPS_DEFINITION = [
|
|
55
|
+
{ name: 'name', type: core_1.StringProperty.TYPE },
|
|
56
|
+
{ name: 'createdBy', type: core_1.StringProperty.TYPE }
|
|
57
|
+
];
|
|
58
|
+
class OKFTelemetryObject extends backend_2.PersistedBaseObject {
|
|
59
|
+
}
|
|
60
|
+
OKFTelemetryObject.COLLECTION = 'telemetry';
|
|
61
|
+
OKFTelemetryObject.PROPS_DEFINITION = [
|
|
62
|
+
{ name: 'type', type: core_1.StringProperty.TYPE },
|
|
63
|
+
{ name: 'bassin', type: core_1.StringProperty.TYPE },
|
|
64
|
+
{ name: 'createdAt', type: core_1.DateTimeProperty.TYPE },
|
|
65
|
+
{ name: 'createdBy', type: core_1.StringProperty.TYPE }
|
|
66
|
+
];
|
|
67
|
+
describe('OKFBackendAdapter', () => {
|
|
68
|
+
const testDir = path.resolve(__dirname, '__test_okf_data__');
|
|
69
|
+
let adapter;
|
|
70
|
+
beforeAll(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
71
|
+
yield fs.mkdir(testDir, { recursive: true });
|
|
72
|
+
adapter = new OKFBackendAdapter_1.OKFBackendAdapter({
|
|
73
|
+
config: { database: testDir }
|
|
74
|
+
});
|
|
75
|
+
backend_1.Backend.addBackend(adapter, 'okf_test', true);
|
|
76
|
+
}));
|
|
77
|
+
afterAll(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
78
|
+
yield fs.rm(testDir, { recursive: true, force: true });
|
|
79
|
+
}));
|
|
80
|
+
test('should format relative OKF filepath correctly for regular objects', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
81
|
+
const obj = yield OKFTestObject.factory();
|
|
82
|
+
const relativePath = adapter.getOKFPath(obj.dataObject, '123-abc');
|
|
83
|
+
expect(relativePath).toBe(path.join('test_collection', '123-abc.json'));
|
|
84
|
+
}));
|
|
85
|
+
test('should format relative OKF filepath correctly for telemetry', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
const dateString = '2026-07-12T14:30:45.120Z';
|
|
87
|
+
const obj = yield OKFTelemetryObject.factory();
|
|
88
|
+
obj.set('type', 'ph');
|
|
89
|
+
obj.set('bassin', 'bac-04');
|
|
90
|
+
obj.set('createdAt', dateString);
|
|
91
|
+
const relativePath = adapter.getOKFPath(obj.dataObject, 'some-uid');
|
|
92
|
+
// Should result in telemetry/2026-07-12/ph/143045-120-bac-04.json
|
|
93
|
+
expect(relativePath).toBe(path.join('telemetry', '2026-07-12', 'ph', '143045-120-bac-04.json'));
|
|
94
|
+
}));
|
|
95
|
+
test('should perform CRUD operations', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
96
|
+
// 1. Create
|
|
97
|
+
const obj = yield OKFTestObject.factory();
|
|
98
|
+
obj.set('name', 'Poisson Tilapia');
|
|
99
|
+
obj.set('createdBy', 'pascal@sodav.ci');
|
|
100
|
+
yield obj.save();
|
|
101
|
+
expect(obj.dataObject.uri.uid).toBeDefined();
|
|
102
|
+
const savedUid = obj.dataObject.uri.uid;
|
|
103
|
+
const expectedPath = path.join(testDir, 'test_collection', `${savedUid}.json`);
|
|
104
|
+
const fileExists = yield fs.stat(expectedPath).then(() => true).catch(() => false);
|
|
105
|
+
expect(fileExists).toBe(true);
|
|
106
|
+
// Verify file content structure
|
|
107
|
+
const raw = yield fs.readFile(expectedPath, 'utf-8');
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
expect(parsed.meta.created_by).toBe('pascal@sodav.ci');
|
|
110
|
+
expect(parsed.data.name).toBe('Poisson Tilapia');
|
|
111
|
+
// 2. Read
|
|
112
|
+
const loaded = yield OKFTestObject.fromBackend(savedUid);
|
|
113
|
+
expect(loaded.val('name')).toBe('Poisson Tilapia');
|
|
114
|
+
// 3. Update
|
|
115
|
+
loaded.set('name', 'Tilapia Rouge');
|
|
116
|
+
yield loaded.save();
|
|
117
|
+
const loaded2 = yield OKFTestObject.fromBackend(savedUid);
|
|
118
|
+
expect(loaded2.val('name')).toBe('Tilapia Rouge');
|
|
119
|
+
// 4. Find
|
|
120
|
+
const results = yield OKFTestObject.repository().query(OKFTestObject.query());
|
|
121
|
+
expect(results.items.length).toBe(1);
|
|
122
|
+
expect(results.items[0].val('name')).toBe('Tilapia Rouge');
|
|
123
|
+
// 5. Delete
|
|
124
|
+
yield loaded2.delete();
|
|
125
|
+
const fileExistsAfterDelete = yield fs.stat(expectedPath).then(() => true).catch(() => false);
|
|
126
|
+
expect(fileExistsAfterDelete).toBe(false);
|
|
127
|
+
}));
|
|
128
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './OKFBackendAdapter';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./OKFBackendAdapter"), exports);
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quatrain/okf",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Open Knowledge Format (OKF) flat file storage adapter",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bun": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE.md",
|
|
10
|
+
"dist/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Quatrain/Core.git",
|
|
17
|
+
"directory": "packages/okf"
|
|
18
|
+
},
|
|
19
|
+
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
20
|
+
"license": "AGPL-3.0-only",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tsconfig/recommended": "^1.0.1",
|
|
23
|
+
"@types/jest": "^29.5.12",
|
|
24
|
+
"@types/node": "^22.10.1",
|
|
25
|
+
"jest": "^29.7.0",
|
|
26
|
+
"ts-jest": "^29.4.6",
|
|
27
|
+
"typescript": "^5.2.2"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@quatrain/backend": "^1.2.17",
|
|
31
|
+
"@quatrain/log": "^1.2.4",
|
|
32
|
+
"@quatrain/types": "^1.2.16"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test-ci": "jest --runInBand",
|
|
36
|
+
"build": "tsc"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { OKFBackendAdapter } from './OKFBackendAdapter';
|
|
4
|
+
import { Backend } from '@quatrain/backend';
|
|
5
|
+
import { PersistedBaseObject } from '@quatrain/backend';
|
|
6
|
+
import { StringProperty, DateTimeProperty } from '@quatrain/core';
|
|
7
|
+
|
|
8
|
+
class OKFTestObject extends PersistedBaseObject {
|
|
9
|
+
static COLLECTION = 'test_collection';
|
|
10
|
+
static PROPS_DEFINITION = [
|
|
11
|
+
{ name: 'name', type: StringProperty.TYPE },
|
|
12
|
+
{ name: 'createdBy', type: StringProperty.TYPE }
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class OKFTelemetryObject extends PersistedBaseObject {
|
|
17
|
+
static COLLECTION = 'telemetry';
|
|
18
|
+
static PROPS_DEFINITION = [
|
|
19
|
+
{ name: 'type', type: StringProperty.TYPE },
|
|
20
|
+
{ name: 'bassin', type: StringProperty.TYPE },
|
|
21
|
+
{ name: 'createdAt', type: DateTimeProperty.TYPE },
|
|
22
|
+
{ name: 'createdBy', type: StringProperty.TYPE }
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('OKFBackendAdapter', () => {
|
|
27
|
+
const testDir = path.resolve(__dirname, '__test_okf_data__');
|
|
28
|
+
let adapter: OKFBackendAdapter;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
32
|
+
adapter = new OKFBackendAdapter({
|
|
33
|
+
config: { database: testDir }
|
|
34
|
+
});
|
|
35
|
+
Backend.addBackend(adapter, 'okf_test', true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('should format relative OKF filepath correctly for regular objects', async () => {
|
|
43
|
+
const obj = await OKFTestObject.factory();
|
|
44
|
+
const relativePath = adapter.getOKFPath(obj.dataObject, '123-abc');
|
|
45
|
+
expect(relativePath).toBe(path.join('test_collection', '123-abc.json'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('should format relative OKF filepath correctly for telemetry', async () => {
|
|
49
|
+
const dateString = '2026-07-12T14:30:45.120Z';
|
|
50
|
+
const obj = await OKFTelemetryObject.factory();
|
|
51
|
+
obj.set('type', 'ph');
|
|
52
|
+
obj.set('bassin', 'bac-04');
|
|
53
|
+
obj.set('createdAt', dateString);
|
|
54
|
+
|
|
55
|
+
const relativePath = adapter.getOKFPath(obj.dataObject, 'some-uid');
|
|
56
|
+
// Should result in telemetry/2026-07-12/ph/143045-120-bac-04.json
|
|
57
|
+
expect(relativePath).toBe(path.join('telemetry', '2026-07-12', 'ph', '143045-120-bac-04.json'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('should perform CRUD operations', async () => {
|
|
61
|
+
// 1. Create
|
|
62
|
+
const obj = await OKFTestObject.factory();
|
|
63
|
+
obj.set('name', 'Poisson Tilapia');
|
|
64
|
+
obj.set('createdBy', 'pascal@sodav.ci');
|
|
65
|
+
await obj.save();
|
|
66
|
+
expect(obj.dataObject.uri.uid).toBeDefined();
|
|
67
|
+
|
|
68
|
+
const savedUid = obj.dataObject.uri.uid;
|
|
69
|
+
const expectedPath = path.join(testDir, 'test_collection', `${savedUid}.json`);
|
|
70
|
+
const fileExists = await fs.stat(expectedPath).then(() => true).catch(() => false);
|
|
71
|
+
expect(fileExists).toBe(true);
|
|
72
|
+
|
|
73
|
+
// Verify file content structure
|
|
74
|
+
const raw = await fs.readFile(expectedPath, 'utf-8');
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
expect(parsed.meta.created_by).toBe('pascal@sodav.ci');
|
|
77
|
+
expect(parsed.data.name).toBe('Poisson Tilapia');
|
|
78
|
+
|
|
79
|
+
// 2. Read
|
|
80
|
+
const loaded = await OKFTestObject.fromBackend<OKFTestObject>(savedUid);
|
|
81
|
+
expect(loaded.val('name')).toBe('Poisson Tilapia');
|
|
82
|
+
|
|
83
|
+
// 3. Update
|
|
84
|
+
loaded.set('name', 'Tilapia Rouge');
|
|
85
|
+
await loaded.save();
|
|
86
|
+
|
|
87
|
+
const loaded2 = await OKFTestObject.fromBackend<OKFTestObject>(savedUid);
|
|
88
|
+
expect(loaded2.val('name')).toBe('Tilapia Rouge');
|
|
89
|
+
|
|
90
|
+
// 4. Find
|
|
91
|
+
const results = await OKFTestObject.repository().query(OKFTestObject.query());
|
|
92
|
+
expect(results.items.length).toBe(1);
|
|
93
|
+
expect(results.items[0].val('name')).toBe('Tilapia Rouge');
|
|
94
|
+
|
|
95
|
+
// 5. Delete
|
|
96
|
+
await loaded2.delete();
|
|
97
|
+
const fileExistsAfterDelete = await fs.stat(expectedPath).then(() => true).catch(() => false);
|
|
98
|
+
expect(fileExistsAfterDelete).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
AbstractBackendAdapter,
|
|
6
|
+
BackendParameters,
|
|
7
|
+
DataObjectClass,
|
|
8
|
+
QueryResultType,
|
|
9
|
+
Filters,
|
|
10
|
+
Filter,
|
|
11
|
+
SortAndLimit,
|
|
12
|
+
BackendError
|
|
13
|
+
} from '@quatrain/backend';
|
|
14
|
+
import { ObjectUri, NotFoundError } from '@quatrain/core';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* File-based persistence adapter conforming to the Open Knowledge Format (OKF) standard.
|
|
18
|
+
* Serializes entities into flat directory layouts.
|
|
19
|
+
*/
|
|
20
|
+
export class OKFBackendAdapter extends AbstractBackendAdapter {
|
|
21
|
+
protected dataDir: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Instantiates the OKF storage adapter.
|
|
25
|
+
*
|
|
26
|
+
* @param params - Configuration parameters containing database path and optional settings.
|
|
27
|
+
*/
|
|
28
|
+
constructor(params?: BackendParameters) {
|
|
29
|
+
const actualParams = params || {};
|
|
30
|
+
super(actualParams);
|
|
31
|
+
this.dataDir = (actualParams.config?.database as string) || process.env.STUDIO_DATA_DIR || '/data/okf';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the relative storage path matching the OKF collection and metadata hierarchy.
|
|
36
|
+
* For telemetry: telemetry/YYYY-MM-DD/{type}/{HHMMSS}-{millis}-{bassinId}.json
|
|
37
|
+
* For other: {collection}/{uid}.json
|
|
38
|
+
*
|
|
39
|
+
* @param dao - The data object model being persisted.
|
|
40
|
+
* @param uid - The unique identifier of the record.
|
|
41
|
+
* @returns The relative file path string.
|
|
42
|
+
*/
|
|
43
|
+
public getOKFPath(dao: DataObjectClass<any>, uid: string): string {
|
|
44
|
+
const collection = this.getCollection(dao);
|
|
45
|
+
if (!collection) {
|
|
46
|
+
throw new BackendError("[OKF] Cannot resolve path without collection name");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (collection === 'telemetry') {
|
|
50
|
+
const createdAt = dao.val('createdAt') || new Date().toISOString();
|
|
51
|
+
const dateObj = new Date(createdAt);
|
|
52
|
+
const yyyymmdd = dateObj.toISOString().split('T')[0];
|
|
53
|
+
const timePart = dateObj.toISOString().split('T')[1].replace(/Z/g, '');
|
|
54
|
+
const parts = timePart.split('.');
|
|
55
|
+
const hms = parts[0];
|
|
56
|
+
const ms = parts[1];
|
|
57
|
+
const hhmmsstrim = hms.replace(/:/g, '');
|
|
58
|
+
const millis = ms ? ms.substring(0, 3) : '000';
|
|
59
|
+
const type = dao.val('type') || 'o2';
|
|
60
|
+
const bassinId = dao.val('bassin') || 'unknown';
|
|
61
|
+
return path.join('telemetry', yyyymmdd, type, `${hhmmsstrim}-${millis}-${bassinId}.json`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return path.join(collection, `${uid}.json`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Creates a new JSON document on disk matching the OKF structure.
|
|
69
|
+
*
|
|
70
|
+
* @param dao - The payload object.
|
|
71
|
+
* @param desiredUid - Optional custom UID.
|
|
72
|
+
* @returns The hydrated DataObject.
|
|
73
|
+
*/
|
|
74
|
+
async create(dao: DataObjectClass<any>, desiredUid?: string): Promise<DataObjectClass<any>> {
|
|
75
|
+
const uid: string = desiredUid || (dao.val('id') as string) || crypto.randomUUID();
|
|
76
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
77
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
78
|
+
|
|
79
|
+
const payload = {
|
|
80
|
+
meta: {
|
|
81
|
+
version: '1.0',
|
|
82
|
+
created_by: dao.val('createdBy') || 'operator@sodav.ci',
|
|
83
|
+
created_at: dao.val('createdAt') || new Date().toISOString()
|
|
84
|
+
},
|
|
85
|
+
data: dao.toJSON()
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
89
|
+
await fs.writeFile(fullPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
90
|
+
|
|
91
|
+
dao.uri = new ObjectUri(`${this.getCollection(dao)}/${uid}`);
|
|
92
|
+
return dao;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hydrates a DataObject by reading its matching flat file on disk.
|
|
97
|
+
*
|
|
98
|
+
* @param dao - The target skeleton DataObject.
|
|
99
|
+
* @returns The populated DataObject.
|
|
100
|
+
*/
|
|
101
|
+
async read(dao: DataObjectClass<any>): Promise<DataObjectClass<any>> {
|
|
102
|
+
const uid = dao.uri.uid;
|
|
103
|
+
if (!uid) {
|
|
104
|
+
throw new BackendError("[OKF] Cannot read record without a valid UID");
|
|
105
|
+
}
|
|
106
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
107
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
return await dao.populate(parsed.data);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new NotFoundError(`[OKF] Record not found at path: ${relativePath}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Overwrites the JSON document with the updated payload.
|
|
120
|
+
*
|
|
121
|
+
* @param dao - The modified payload.
|
|
122
|
+
* @returns The DataObject instance.
|
|
123
|
+
*/
|
|
124
|
+
async update(dao: DataObjectClass<any>): Promise<DataObjectClass<any>> {
|
|
125
|
+
const uid = dao.uri.uid;
|
|
126
|
+
if (!uid) {
|
|
127
|
+
throw new BackendError("[OKF] Cannot update record without a valid UID");
|
|
128
|
+
}
|
|
129
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
130
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
131
|
+
|
|
132
|
+
const payload = {
|
|
133
|
+
meta: {
|
|
134
|
+
version: '1.0',
|
|
135
|
+
created_by: dao.val('createdBy') || 'operator@sodav.ci',
|
|
136
|
+
created_at: dao.val('createdAt') || new Date().toISOString()
|
|
137
|
+
},
|
|
138
|
+
data: dao.toJSON()
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await fs.writeFile(fullPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
142
|
+
return dao;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Deletes the JSON file from the filesystem.
|
|
147
|
+
*
|
|
148
|
+
* @param dao - The target DataObject to remove.
|
|
149
|
+
* @returns The cleared DataObject.
|
|
150
|
+
*/
|
|
151
|
+
async delete(dao: DataObjectClass<any>): Promise<DataObjectClass<any>> {
|
|
152
|
+
const uid = dao.uri.uid;
|
|
153
|
+
if (!uid) {
|
|
154
|
+
throw new BackendError("[OKF] Cannot delete record without a valid UID");
|
|
155
|
+
}
|
|
156
|
+
const relativePath = this.getOKFPath(dao, uid);
|
|
157
|
+
const fullPath = path.join(this.dataDir, relativePath);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await fs.unlink(fullPath);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new NotFoundError(`[OKF] Failed to delete record at path: ${relativePath}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return dao;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Recursively removes the directory associated with a collection.
|
|
170
|
+
*
|
|
171
|
+
* @param collection - The collection folder path to clear.
|
|
172
|
+
*/
|
|
173
|
+
async deleteCollection(collection: string): Promise<void> {
|
|
174
|
+
const colPath = path.join(this.dataDir, collection);
|
|
175
|
+
await fs.rm(colPath, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Helper to flatten filters into a simple array.
|
|
180
|
+
*/
|
|
181
|
+
protected _flattenFilters(f?: Filters | Filter[]): Filter[] {
|
|
182
|
+
if (!f) return [];
|
|
183
|
+
if (Array.isArray(f)) return f;
|
|
184
|
+
const list: Filter[] = [];
|
|
185
|
+
if (f.and) list.push(...f.and);
|
|
186
|
+
if (f.or) list.push(...f.or);
|
|
187
|
+
return list;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Queries the local files by scanning directories and evaluating conditions in-memory.
|
|
192
|
+
*
|
|
193
|
+
* @param dataObject - Base template representing the target collection.
|
|
194
|
+
* @param filters - Active constraints.
|
|
195
|
+
* @param pagination - Limits and sorting directives.
|
|
196
|
+
* @returns QueryResult containing found items and diagnostics.
|
|
197
|
+
*/
|
|
198
|
+
async find(
|
|
199
|
+
dataObject: DataObjectClass<any>,
|
|
200
|
+
filters?: Filters | Filter[],
|
|
201
|
+
pagination?: SortAndLimit
|
|
202
|
+
): Promise<QueryResultType<any>> {
|
|
203
|
+
const collection = this.getCollection(dataObject);
|
|
204
|
+
if (!collection) {
|
|
205
|
+
throw new BackendError("[OKF] Cannot query without a valid collection name");
|
|
206
|
+
}
|
|
207
|
+
const items: DataObjectClass<any>[] = [];
|
|
208
|
+
const baseColPath = path.join(this.dataDir, collection);
|
|
209
|
+
|
|
210
|
+
const scanDir = async (dir: string) => {
|
|
211
|
+
try {
|
|
212
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const fullPath = path.join(dir, entry.name);
|
|
215
|
+
if (entry.isDirectory()) {
|
|
216
|
+
await scanDir(fullPath);
|
|
217
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
218
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
219
|
+
const parsed = JSON.parse(content);
|
|
220
|
+
const dao = await dataObject.clone(parsed.data);
|
|
221
|
+
|
|
222
|
+
let keep = true;
|
|
223
|
+
if (filters) {
|
|
224
|
+
const filterArr = this._flattenFilters(filters);
|
|
225
|
+
for (const filter of filterArr) {
|
|
226
|
+
const prop = dao.get(filter.prop);
|
|
227
|
+
if (typeof prop === 'undefined') {
|
|
228
|
+
keep = false;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
if (prop.val() !== filter.value) {
|
|
232
|
+
keep = false;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (keep) {
|
|
239
|
+
items.push(dao);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// Ignore missing folders
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await scanDir(baseColPath);
|
|
249
|
+
|
|
250
|
+
// Simple slice implementation for limit/offset
|
|
251
|
+
let slicedItems = items;
|
|
252
|
+
if (pagination && pagination.limits) {
|
|
253
|
+
const offset = pagination.limits.offset || 0;
|
|
254
|
+
const batch = pagination.limits.batch || items.length;
|
|
255
|
+
slicedItems = items.slice(offset, offset + batch);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
items: slicedItems,
|
|
260
|
+
meta: {
|
|
261
|
+
count: items.length,
|
|
262
|
+
offset: pagination?.limits.offset || 0,
|
|
263
|
+
batch: pagination?.limits.batch || items.length,
|
|
264
|
+
executionTime: Date.now()
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
generateCreateSql() { return { upSql: '', downSql: '' }; }
|
|
270
|
+
generateDeltaSql() { return { upSql: [], downSql: [] }; }
|
|
271
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './OKFBackendAdapter'
|