@abelionorg/core 3.5.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/package.json +32 -0
- package/src/Arsip.ts +513 -0
- package/src/AuditLog.ts +55 -0
- package/src/Brankas.ts +322 -0
- package/src/CrashReporter.ts +45 -0
- package/src/Gudang.ts +63 -0
- package/src/GunakanTunda.ts +19 -0
- package/src/Indera.ts +61 -0
- package/src/Integritas.ts +36 -0
- package/src/Kalender.ts +59 -0
- package/src/KataSandi.ts +27 -0
- package/src/Laras.ts +179 -0
- package/src/Layanan.ts +45 -0
- package/src/Linguis.ts +73 -0
- package/src/Penjaga.ts +70 -0
- package/src/Penyaring.ts +10 -0
- package/src/Penyelaras.ts +40 -0
- package/src/Pujangga.ts +66 -0
- package/src/Pundi.ts +69 -0
- package/src/Rumus.ts +55 -0
- package/src/Sentinel.ts +129 -0
- package/src/Waktu.ts +18 -0
- package/src/ai/GeminiProvider.ts +36 -0
- package/src/ai/LocalProvider.ts +42 -0
- package/src/ai/PenyaringRahasia.ts +31 -0
- package/src/ai/types.ts +12 -0
- package/src/index.ts +23 -0
- package/src/scripts/bench-all-notes.ts +23 -0
- package/src/scripts/inject-perf-data.ts +51 -0
- package/src/storage/BrowserAdapter.ts +68 -0
- package/src/storage/FileAdapter.shim.ts +5 -0
- package/src/storage/FileAdapter.ts +121 -0
- package/src/storage/types.ts +31 -0
package/src/Sentinel.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel: Security hardening module
|
|
3
|
+
* Rate limiting, constant-time comparison, and brute-force protection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface RateLimitState {
|
|
7
|
+
attempts: number;
|
|
8
|
+
lastAttempt: number;
|
|
9
|
+
lockoutUntil?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_ATTEMPTS = 5;
|
|
13
|
+
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
14
|
+
const rateLimitStore = new Map<string, RateLimitState>();
|
|
15
|
+
|
|
16
|
+
export const Sentinel = {
|
|
17
|
+
constantTimeCompare(a: string, b: string): boolean {
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
const aBytes = encoder.encode(a);
|
|
20
|
+
const bBytes = encoder.encode(b);
|
|
21
|
+
|
|
22
|
+
let result = 0;
|
|
23
|
+
const len = aBytes.length;
|
|
24
|
+
|
|
25
|
+
// Length check (still constant-time)
|
|
26
|
+
if (len !== bBytes.length) {
|
|
27
|
+
result = 1; // Mismatch
|
|
28
|
+
// Dummy operation to avoid timing clues based on string length differences
|
|
29
|
+
for (let i = 0; i < bBytes.length; i++) {
|
|
30
|
+
result |= bBytes[i] ^ bBytes[i];
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Byte-by-byte comparison (constant-time)
|
|
36
|
+
for (let i = 0; i < len; i++) {
|
|
37
|
+
result |= aBytes[i] ^ bBytes[i];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result === 0;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if operation is rate-limited
|
|
45
|
+
* @param key Unique identifier (e.g., vault ID, IP, user)
|
|
46
|
+
* @returns { allowed: boolean, remaining?: number, resetAt?: number }
|
|
47
|
+
*/
|
|
48
|
+
checkRateLimit(key: string): { allowed: boolean; remaining?: number; resetAt?: number } {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const state = rateLimitStore.get(key);
|
|
51
|
+
|
|
52
|
+
// No previous attempts - allow
|
|
53
|
+
if (!state) {
|
|
54
|
+
rateLimitStore.set(key, {
|
|
55
|
+
attempts: 1,
|
|
56
|
+
lastAttempt: now,
|
|
57
|
+
});
|
|
58
|
+
return { allowed: true, remaining: MAX_ATTEMPTS - 1 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if lockout period has expired
|
|
62
|
+
if (state.lockoutUntil) {
|
|
63
|
+
if (now < state.lockoutUntil) {
|
|
64
|
+
return {
|
|
65
|
+
allowed: false,
|
|
66
|
+
resetAt: state.lockoutUntil,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Lockout expired - reset
|
|
70
|
+
rateLimitStore.set(key, {
|
|
71
|
+
attempts: 1,
|
|
72
|
+
lastAttempt: now,
|
|
73
|
+
});
|
|
74
|
+
return { allowed: true, remaining: MAX_ATTEMPTS - 1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if we're still in the same minute window
|
|
78
|
+
const timeSinceLastAttempt = now - state.lastAttempt;
|
|
79
|
+
if (timeSinceLastAttempt > 60 * 1000) {
|
|
80
|
+
// Window expired - reset
|
|
81
|
+
rateLimitStore.set(key, {
|
|
82
|
+
attempts: 1,
|
|
83
|
+
lastAttempt: now,
|
|
84
|
+
});
|
|
85
|
+
return { allowed: true, remaining: MAX_ATTEMPTS - 1 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check if max attempts reached
|
|
89
|
+
if (state.attempts >= MAX_ATTEMPTS) {
|
|
90
|
+
// Lockout
|
|
91
|
+
const lockoutUntil = now + LOCKOUT_DURATION;
|
|
92
|
+
rateLimitStore.set(key, {
|
|
93
|
+
attempts: state.attempts,
|
|
94
|
+
lastAttempt: now,
|
|
95
|
+
lockoutUntil,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
allowed: false,
|
|
99
|
+
resetAt: lockoutUntil,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Increment attempts
|
|
104
|
+
rateLimitStore.set(key, {
|
|
105
|
+
attempts: state.attempts + 1,
|
|
106
|
+
lastAttempt: now,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
allowed: true,
|
|
111
|
+
remaining: MAX_ATTEMPTS - state.attempts - 1,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reset rate limit for a key (e.g., after successful unlock)
|
|
117
|
+
* @param key Unique identifier
|
|
118
|
+
*/
|
|
119
|
+
resetRateLimit(key: string): void {
|
|
120
|
+
rateLimitStore.delete(key);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clear all rate limit data (e.g., on app shutdown)
|
|
125
|
+
*/
|
|
126
|
+
clearAllRateLimits(): void {
|
|
127
|
+
rateLimitStore.clear();
|
|
128
|
+
},
|
|
129
|
+
};
|
package/src/Waktu.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const formatter = new Intl.DateTimeFormat("id-ID", { day: "numeric", month: "short" });
|
|
2
|
+
|
|
3
|
+
export const formatWaktuRelatif = (isoString: string): string => {
|
|
4
|
+
const date = new Date(isoString);
|
|
5
|
+
const now = new Date();
|
|
6
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
7
|
+
|
|
8
|
+
if (diffInSeconds < 60) return 'Baru saja';
|
|
9
|
+
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} menit lalu`;
|
|
10
|
+
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} jam lalu`;
|
|
11
|
+
|
|
12
|
+
const diffInDays = Math.floor(diffInSeconds / 86400);
|
|
13
|
+
if (diffInDays === 1) return 'Kemarin';
|
|
14
|
+
if (diffInDays < 7) return `${diffInDays} hari lalu`;
|
|
15
|
+
|
|
16
|
+
// Use pre-instantiated formatter for ~190x performance gain in fallback cases
|
|
17
|
+
return formatter.format(date);
|
|
18
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import { PujanggaProvider, PujanggaPlan } from './types';
|
|
3
|
+
|
|
4
|
+
export class GeminiProvider implements PujanggaProvider {
|
|
5
|
+
id = 'gemini';
|
|
6
|
+
name = 'Google Gemini';
|
|
7
|
+
|
|
8
|
+
async berpikir(konteks: string, instruksi: string): Promise<PujanggaPlan> {
|
|
9
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
10
|
+
if (!apiKey) throw new Error('GEMINI_API_KEY tidak ditemukan.');
|
|
11
|
+
|
|
12
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
13
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
|
14
|
+
|
|
15
|
+
const prompt = `
|
|
16
|
+
KONTEKS:
|
|
17
|
+
${konteks}
|
|
18
|
+
|
|
19
|
+
INSTRUKSI:
|
|
20
|
+
${instruksi}
|
|
21
|
+
|
|
22
|
+
Kembalikan ONLY JSON valid:
|
|
23
|
+
{
|
|
24
|
+
"keputusan": "STRING",
|
|
25
|
+
"alasan": "STRING",
|
|
26
|
+
"perintah_sistem": "STRING | null",
|
|
27
|
+
"catatan_internal": "STRING"
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const result = await model.generateContent(prompt);
|
|
32
|
+
const text = result.response.text();
|
|
33
|
+
const jsonStr = text.replace(/```json|```/g, '').trim();
|
|
34
|
+
return JSON.parse(jsonStr);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { PujanggaProvider, PujanggaPlan } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LocalProvider (Hardened)
|
|
5
|
+
* Menjalankan logika otonom dasar secara OFFLINE tanpa bantuan AI eksternal.
|
|
6
|
+
* Menjamin 100% kedaulatan data.
|
|
7
|
+
*/
|
|
8
|
+
export class LocalProvider implements PujanggaProvider {
|
|
9
|
+
id = 'local';
|
|
10
|
+
name = 'Logika Lokal (Berdikari)';
|
|
11
|
+
|
|
12
|
+
async berpikir(konteks: string, instruksi: string): Promise<PujanggaPlan> {
|
|
13
|
+
const cleanKonteks = konteks.toLowerCase();
|
|
14
|
+
const cleanInstruksi = instruksi.toLowerCase();
|
|
15
|
+
|
|
16
|
+
// Aturan Heuristik untuk Tugas Umum
|
|
17
|
+
if (cleanKonteks.includes('audit') || cleanInstruksi.includes('keamanan') || cleanInstruksi.includes('periksa')) {
|
|
18
|
+
return {
|
|
19
|
+
keputusan: 'Melakukan pemindaian integritas dan keamanan lokal.',
|
|
20
|
+
alasan: 'Mode Berdikari aktif. Menjalankan skrip audit keamanan internal.',
|
|
21
|
+
perintah_sistem: null, // Bisa diisi dengan skrip audit jika tersedia
|
|
22
|
+
catatan_internal: 'Sentinel mengamankan pelataran dari kebocoran rahasia secara offline.'
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (cleanInstruksi.includes('statistik') || cleanInstruksi.includes('lapor')) {
|
|
27
|
+
return {
|
|
28
|
+
keputusan: 'Menyusun laporan statistik brankas.',
|
|
29
|
+
alasan: 'Analisis metadata lokal.',
|
|
30
|
+
perintah_sistem: 'lembaran pantau',
|
|
31
|
+
catatan_internal: 'Menampilkan ringkasan kesehatan sistem kepada pengguna.'
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
keputusan: 'Tugas tertunda atau diproses secara manual.',
|
|
37
|
+
alasan: 'Mode Berdikari membatasi eksekusi otonom demi keamanan maksimal.',
|
|
38
|
+
perintah_sistem: null,
|
|
39
|
+
catatan_internal: 'Gunakan mode Gemini jika memerlukan analisis kecerdasan buatan yang mendalam.'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PenyaringRahasia (Secret Scrubber)
|
|
3
|
+
* Membersihkan data sensitif (API Keys, Passwords, Tokens) sebelum dikirim ke AI.
|
|
4
|
+
*/
|
|
5
|
+
export class PenyaringRahasia {
|
|
6
|
+
private static readonly REGEX_PATTERNS = [
|
|
7
|
+
/(?:api_key|secret|password|token|pwd|kunci|sandi|rahasia)\s*[:=]\s*["']?([^"'\s,|}]+)["']?/gi,
|
|
8
|
+
/(?:AI[a-zA-Z0-9_-]{32,})/g,
|
|
9
|
+
/(?:ghp_[a-zA-Z0-9]{36})/g,
|
|
10
|
+
/(?:sk-[a-zA-Z0-9]{48})/g, // OpenAI keys
|
|
11
|
+
/(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g,
|
|
12
|
+
/(?:"pass(?:word)?":\s*")[^"]+(")/gi
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Menyaring teks dari rahasia yang terdeteksi.
|
|
17
|
+
*/
|
|
18
|
+
static saring(konten: string): string {
|
|
19
|
+
if (!konten) return konten;
|
|
20
|
+
let hasil = konten;
|
|
21
|
+
for (const pattern of this.REGEX_PATTERNS) {
|
|
22
|
+
hasil = hasil.replace(pattern, (match, p1) => {
|
|
23
|
+
if (p1) {
|
|
24
|
+
return match.replace(p1, '[RAHASIA_TERLINDUNGI]');
|
|
25
|
+
}
|
|
26
|
+
return '[RAHASIA_TERLINDUNGI]';
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return hasil;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/ai/types.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface PujanggaPlan {
|
|
2
|
+
keputusan: string;
|
|
3
|
+
alasan: string;
|
|
4
|
+
perintah_sistem: string | null;
|
|
5
|
+
catatan_internal: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PujanggaProvider {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
berpikir(konteks: string, instruksi: string): Promise<PujanggaPlan>;
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from './Arsip';
|
|
2
|
+
export * from './AuditLog';
|
|
3
|
+
export * from './CrashReporter';
|
|
4
|
+
export * from './Brankas';
|
|
5
|
+
export * from './Gudang';
|
|
6
|
+
export * from './GunakanTunda';
|
|
7
|
+
export * from './Indera';
|
|
8
|
+
export * from './Integritas';
|
|
9
|
+
export * from './Kalender';
|
|
10
|
+
export * from './KataSandi';
|
|
11
|
+
export * from './Laras';
|
|
12
|
+
export * from './Layanan';
|
|
13
|
+
export * from './Penjaga';
|
|
14
|
+
export * from './Penyaring';
|
|
15
|
+
export * from './Penyelaras';
|
|
16
|
+
export * from './Pujangga';
|
|
17
|
+
export * from './Linguis';
|
|
18
|
+
export * from './Pundi';
|
|
19
|
+
export * from './Rumus';
|
|
20
|
+
export * from './Waktu';
|
|
21
|
+
export * from './storage/FileAdapter';
|
|
22
|
+
export * from './storage/BrowserAdapter';
|
|
23
|
+
export * from './storage/types';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Arsip } from '../Arsip';
|
|
2
|
+
|
|
3
|
+
async function run() {
|
|
4
|
+
const password = 'bolt-speed-test';
|
|
5
|
+
await Arsip.unlockVault(password);
|
|
6
|
+
|
|
7
|
+
console.log('Benchmarking getAllNotes (10 runs)...');
|
|
8
|
+
const times = [];
|
|
9
|
+
for (let i = 0; i < 10; i++) {
|
|
10
|
+
const start = performance.now();
|
|
11
|
+
const _notes = await Arsip.getAllNotes();
|
|
12
|
+
const end = performance.now();
|
|
13
|
+
const duration = end - start;
|
|
14
|
+
console.log(`Run #${i + 1}: ${duration.toFixed(2)}ms`);
|
|
15
|
+
times.push(duration);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
|
19
|
+
console.log(`Average time for 1000 notes: ${avg.toFixed(2)}ms`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
run().catch(console.error);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Arsip } from '../Arsip';
|
|
2
|
+
|
|
3
|
+
async function run() {
|
|
4
|
+
const password = 'bolt-speed-test';
|
|
5
|
+
const isInitialized = await Arsip.isVaultInitialized();
|
|
6
|
+
|
|
7
|
+
if (!isInitialized) {
|
|
8
|
+
console.log('Initializing vault...');
|
|
9
|
+
await Arsip.setupVault(password);
|
|
10
|
+
} else {
|
|
11
|
+
console.log('Unlocking vault...');
|
|
12
|
+
const success = await Arsip.unlockVault(password);
|
|
13
|
+
if (!success) {
|
|
14
|
+
console.error('Failed to unlock vault. Please clear .lembaran-db.json if you forgot the password.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('Injecting 1000 notes...');
|
|
20
|
+
const startTime = Date.now();
|
|
21
|
+
|
|
22
|
+
// Use for loop for sequential injection to avoid potential race conditions in simple FileAdapter
|
|
23
|
+
for (let i = 1; i <= 1000; i++) {
|
|
24
|
+
await Arsip.saveNote({
|
|
25
|
+
title: `Note Performance Test #${i}`,
|
|
26
|
+
content: `Ini adalah catatan ke-${i} untuk pengujian performa Bolt ⚡.
|
|
27
|
+
Catatan ini berisi teks yang cukup panjang untuk mensimulasikan beban kerja nyata.
|
|
28
|
+
Pujangga akan membantu membuatkan ringkasan cerdas dari konten ini.
|
|
29
|
+
Kita akan mencari kata kunci "BOLT_SPECIAL_TOKEN" di beberapa catatan.` +
|
|
30
|
+
(i === 500 || i === 999 ? ' BOLT_SPECIAL_TOKEN' : ''),
|
|
31
|
+
folderId: null,
|
|
32
|
+
isPinned: i % 10 === 0,
|
|
33
|
+
isFavorite: false,
|
|
34
|
+
tags: ['Performance', 'Bolt', i % 2 === 0 ? 'Testing' : 'Benchmark']
|
|
35
|
+
} as any);
|
|
36
|
+
|
|
37
|
+
if (i % 100 === 0) {
|
|
38
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
39
|
+
console.log(`${i} notes injected... (${elapsed.toFixed(1)}s)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const totalTime = (Date.now() - startTime) / 1000;
|
|
44
|
+
console.log(`Success! 1000 notes injected in ${totalTime.toFixed(1)}s.`);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
run().catch(err => {
|
|
49
|
+
console.error('Error during injection:', err);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { openDB, IDBPDatabase } from 'idb';
|
|
2
|
+
import { StorageAdapter, LembaranSchema } from './types';
|
|
3
|
+
|
|
4
|
+
const DB_NAME = 'lembaran_next';
|
|
5
|
+
const DB_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export class BrowserAdapter implements StorageAdapter {
|
|
8
|
+
private dbInstance: IDBPDatabase<LembaranSchema> | null = null;
|
|
9
|
+
|
|
10
|
+
private async initDB(): Promise<IDBPDatabase<LembaranSchema>> {
|
|
11
|
+
if (this.dbInstance) return this.dbInstance;
|
|
12
|
+
|
|
13
|
+
this.dbInstance = await openDB<LembaranSchema>(DB_NAME, DB_VERSION, {
|
|
14
|
+
upgrade(db) {
|
|
15
|
+
if (!db.objectStoreNames.contains('notes')) {
|
|
16
|
+
const noteStore = db.createObjectStore('notes', { keyPath: 'id' });
|
|
17
|
+
noteStore.createIndex('updatedAt', 'updatedAt');
|
|
18
|
+
noteStore.createIndex('folderId', 'folderId');
|
|
19
|
+
}
|
|
20
|
+
if (!db.objectStoreNames.contains('folders')) {
|
|
21
|
+
db.createObjectStore('folders', { keyPath: 'id' });
|
|
22
|
+
}
|
|
23
|
+
if (!db.objectStoreNames.contains('kv')) {
|
|
24
|
+
db.createObjectStore('kv');
|
|
25
|
+
}
|
|
26
|
+
if (!db.objectStoreNames.contains('meta')) {
|
|
27
|
+
db.createObjectStore('meta');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return this.dbInstance;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get<K extends keyof LembaranSchema>(store: K, key: string) {
|
|
36
|
+
const db = await this.initDB();
|
|
37
|
+
return db.get(store as any, key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async set<K extends keyof LembaranSchema>(store: K, key: string, value: LembaranSchema[K]['value']) {
|
|
41
|
+
const db = await this.initDB();
|
|
42
|
+
if (store === 'notes' || store === 'folders') {
|
|
43
|
+
await db.put(store as any, value);
|
|
44
|
+
} else {
|
|
45
|
+
await db.put(store as any, value, key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getAll<K extends keyof LembaranSchema>(store: K) {
|
|
50
|
+
const db = await this.initDB();
|
|
51
|
+
return db.getAll(store as any);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async delete(store: keyof LembaranSchema, key: string) {
|
|
55
|
+
const db = await this.initDB();
|
|
56
|
+
await db.delete(store as any, key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async count(store: keyof LembaranSchema) {
|
|
60
|
+
const db = await this.initDB();
|
|
61
|
+
return db.count(store as any);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async clear(store: keyof LembaranSchema) {
|
|
65
|
+
const db = await this.initDB();
|
|
66
|
+
await db.clear(store as any);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { StorageAdapter, LembaranSchema } from './types';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DB_FILE = '.lembaran-db.json';
|
|
6
|
+
|
|
7
|
+
interface SchemaStructure {
|
|
8
|
+
notes: Record<string, LembaranSchema['notes']['value']>;
|
|
9
|
+
folders: Record<string, LembaranSchema['folders']['value']>;
|
|
10
|
+
kv: Record<string, LembaranSchema['kv']['value']>;
|
|
11
|
+
meta: Record<string, LembaranSchema['meta']['value']>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class FileAdapter implements StorageAdapter {
|
|
15
|
+
private data: SchemaStructure | null = null;
|
|
16
|
+
private filePath: string;
|
|
17
|
+
|
|
18
|
+
constructor(customPath?: string) {
|
|
19
|
+
// Smart path detection: Custom Path > Environment variable > Current Directory
|
|
20
|
+
const envPath = process.env.DB_PATH;
|
|
21
|
+
if (customPath) {
|
|
22
|
+
this.filePath = path.isAbsolute(customPath) ? customPath : path.resolve(process.cwd(), customPath);
|
|
23
|
+
} else if (envPath) {
|
|
24
|
+
this.filePath = path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
|
|
25
|
+
} else {
|
|
26
|
+
this.filePath = path.resolve(process.cwd(), DEFAULT_DB_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.env.DEBUG === 'true') {
|
|
30
|
+
console.log(`[FILE_ADAPTER] Open: ${this.filePath}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async ensureDirectory(): Promise<void> {
|
|
35
|
+
const dir = path.dirname(this.filePath);
|
|
36
|
+
try {
|
|
37
|
+
await fs.mkdir(dir, { recursive: true });
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
// Directory might already exist
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async load(): Promise<SchemaStructure> {
|
|
44
|
+
if (this.data) return this.data;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const content = await fs.readFile(this.filePath, 'utf-8');
|
|
48
|
+
const parsed = JSON.parse(content);
|
|
49
|
+
this.data = {
|
|
50
|
+
notes: parsed.notes || {},
|
|
51
|
+
folders: parsed.folders || {},
|
|
52
|
+
kv: parsed.kv || {},
|
|
53
|
+
meta: parsed.meta || {}
|
|
54
|
+
};
|
|
55
|
+
} catch (_error) {
|
|
56
|
+
this.data = {
|
|
57
|
+
notes: {},
|
|
58
|
+
folders: {},
|
|
59
|
+
kv: {},
|
|
60
|
+
meta: {}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return this.data!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async save(): Promise<void> {
|
|
67
|
+
if (!this.data) return;
|
|
68
|
+
try {
|
|
69
|
+
await this.ensureDirectory();
|
|
70
|
+
await fs.writeFile(this.filePath, JSON.stringify(this.data, null, 2));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(`[FILE_ADAPTER_ERROR] Gagal menyimpan ke ${this.filePath}:`, error);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async get<K extends keyof LembaranSchema>(store: K, key: string) {
|
|
78
|
+
const data = await this.load();
|
|
79
|
+
// @ts-expect-error - dynamic store access
|
|
80
|
+
return data[store][key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async set<K extends keyof LembaranSchema>(store: K, key: string, value: LembaranSchema[K]['value']) {
|
|
84
|
+
const data = await this.load();
|
|
85
|
+
|
|
86
|
+
let actualKey = key;
|
|
87
|
+
if ((store === 'notes' || store === 'folders') && (value as { id?: string }).id) {
|
|
88
|
+
actualKey = (value as { id: string }).id;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// @ts-expect-error - dynamic store access
|
|
92
|
+
data[store][actualKey] = value;
|
|
93
|
+
await this.save();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getAll<K extends keyof LembaranSchema>(store: K) {
|
|
97
|
+
const data = await this.load();
|
|
98
|
+
// @ts-expect-error - dynamic store access
|
|
99
|
+
return Object.values(data[store]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async delete(store: keyof LembaranSchema, key: string) {
|
|
103
|
+
const data = await this.load();
|
|
104
|
+
// @ts-expect-error - dynamic store access
|
|
105
|
+
delete data[store][key];
|
|
106
|
+
await this.save();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async count(store: keyof LembaranSchema) {
|
|
110
|
+
const data = await this.load();
|
|
111
|
+
// @ts-expect-error - dynamic store access
|
|
112
|
+
return Object.keys(data[store]).length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async clear(store: keyof LembaranSchema) {
|
|
116
|
+
const data = await this.load();
|
|
117
|
+
// @ts-expect-error - dynamic store access
|
|
118
|
+
data[store] = {};
|
|
119
|
+
await this.save();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Note, Folder, AppSettings, UserProfile } from '../Rumus';
|
|
2
|
+
import { DBSchema } from 'idb';
|
|
3
|
+
|
|
4
|
+
export interface LembaranSchema extends DBSchema {
|
|
5
|
+
notes: {
|
|
6
|
+
key: string;
|
|
7
|
+
value: Note;
|
|
8
|
+
indexes: { 'updatedAt': string; 'folderId': string };
|
|
9
|
+
};
|
|
10
|
+
folders: {
|
|
11
|
+
key: string;
|
|
12
|
+
value: Folder;
|
|
13
|
+
};
|
|
14
|
+
kv: {
|
|
15
|
+
key: string;
|
|
16
|
+
value: AppSettings | UserProfile | unknown;
|
|
17
|
+
};
|
|
18
|
+
meta: {
|
|
19
|
+
key: string;
|
|
20
|
+
value: unknown;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StorageAdapter {
|
|
25
|
+
get<K extends keyof LembaranSchema>(store: K, key: string): Promise<LembaranSchema[K]['value'] | undefined>;
|
|
26
|
+
set<K extends keyof LembaranSchema>(store: K, key: string, value: LembaranSchema[K]['value']): Promise<void>;
|
|
27
|
+
getAll<K extends keyof LembaranSchema>(store: K): Promise<LembaranSchema[K]['value'][]>;
|
|
28
|
+
delete(store: keyof LembaranSchema, key: string): Promise<void>;
|
|
29
|
+
count(store: keyof LembaranSchema): Promise<number>;
|
|
30
|
+
clear(store: keyof LembaranSchema): Promise<void>;
|
|
31
|
+
}
|