gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmaster87888725release 1.0.7caramboleyomaster/src/IndexManager.js

20.2 KB

  1. import { writeFile, readFile, open, rename, stat } from 'node:fs/promises';
  2. import { createWriteStream } from 'node:fs';
  3. import { resolve } from 'node:path';
  4. import { deserialize } from './mpack.js';
  5. import { PrimaryKeyType, IndexType } from './MPackDB.js';
  6. const BLOCK_SIZE = 4096; // 4KB
  7. /**
  8. * Manages indexes for the database (in-memory deltas + on-disk persistence)
  9. */
  10. export class IndexManager {
  11. _dbDir;
  12. _dbName;
  13. _indexes;
  14. _indexTypes;
  15. _primaryKeyType;
  16. _persistIntervalId = null;
  17. _indexPaths = {};
  18. _deltaIndexes = {};
  19. _tombstones = {};
  20. _persistThreshold = 1000;
  21. _totalDeltaCount = 0;
  22. _coveredBytes = 0;
  23. _idxStatePath = null;
  24. _catchUpPromise = null;
  25. _runExclusive = fn => fn();
  26. /**
  27. * Creates a new IndexManager
  28. * @param {string} dbDir - Database directory path
  29. * @param {string} dbName - Database name
  30. * @param {string[]} indexes - Array of field names to index
  31. * @param {Object} indexTypes - Map of field names to IndexType
  32. * @param {number} primaryKeyType - Primary key type
  33. * @param {Object} [options]
  34. * @param {Function} [options.runExclusive] - Wraps index file writes in the db's lock
  35. */
  36. constructor(dbDir, dbName, indexes, indexTypes, primaryKeyType, { runExclusive } = {}) {
  37. this._dbDir = dbDir;
  38. this._dbName = dbName;
  39. this._indexes = indexes;
  40. this._indexTypes = indexTypes;
  41. this._primaryKeyType = primaryKeyType;
  42. if (runExclusive) this._runExclusive = runExclusive;
  43. // Index paths are deterministic and do not depend on async initialization.
  44. // Populate them immediately, then repair a missing slot on access so a
  45. // declared index can never leak readFile(undefined) to the caller.
  46. for (const field of this._indexes) this._getIndexPath(field);
  47. }
  48. _getIndexPath(field) {
  49. if (!this._indexes.includes(field)) {
  50. const configured = this._indexes.length > 0 ? this._indexes.join(', ') : '(none)';
  51. const error = new Error(`No index configured for field "${String(field)}". Configured indexes: ${configured}`);
  52. error.code = 'INDEX_NOT_FOUND';
  53. error.field = field;
  54. error.indexes = [...this._indexes];
  55. throw error;
  56. }
  57. const expectedPath = resolve(this._dbDir, `${this._dbName}.${field}.txt`);
  58. if (this._indexPaths[field] !== expectedPath) {
  59. this._indexPaths[field] = expectedPath;
  60. }
  61. return expectedPath;
  62. }
  63. /**
  64. * Initializes the index manager (rebuilds indexes from data file)
  65. * @param {string} dataPath - Path to the data file
  66. * @returns {Promise<void>}
  67. */
  68. async init(dataPath, { forceRebuild = false } = {}) {
  69. this._idxStatePath = resolve(this._dbDir, `${this._dbName}.idxstate.json`);
  70. let needRebuild = forceRebuild;
  71. for (const field of this._indexes) {
  72. this._deltaIndexes[field] = [];
  73. this._tombstones[field] = new Map();
  74. const indexPath = this._getIndexPath(field);
  75. if (needRebuild) continue;
  76. try {
  77. const stats = await stat(indexPath);
  78. if (stats.size === 0) {
  79. const dataStats = await stat(dataPath).catch(() => ({ size: 0 }));
  80. if (dataStats.size > 0) needRebuild = true;
  81. }
  82. } catch (e) {
  83. if (e.code === 'ENOENT') {
  84. const dataStats = await stat(dataPath).catch(() => ({ size: 0 }));
  85. if (dataStats.size > 0) needRebuild = true;
  86. } else {
  87. throw e;
  88. }
  89. }
  90. }
  91. // coveredBytes: how far into the data file the on-disk indexes reach.
  92. // Without it we cannot trust existing index files (another process may
  93. // have appended records it never persisted) — rebuild once to establish it.
  94. if (!needRebuild) {
  95. const state = await this._readIdxState();
  96. if (state === null) {
  97. needRebuild = true;
  98. } else {
  99. this._coveredBytes = state.coveredBytes || 0;
  100. }
  101. }
  102. if (needRebuild) {
  103. for (const field of this._indexes) {
  104. await this._rebuildIndex(dataPath, field);
  105. }
  106. this._coveredBytes = (await stat(dataPath).catch(() => ({ size: 0 }))).size;
  107. await this._writeIdxState();
  108. }
  109. }
  110. async _readIdxState() {
  111. try {
  112. return JSON.parse(await readFile(this._idxStatePath, 'utf-8'));
  113. } catch (e) {
  114. return null;
  115. }
  116. }
  117. async _writeIdxState() {
  118. const tmpPath = `${this._idxStatePath}.${process.pid}.tmp`;
  119. await writeFile(tmpPath, JSON.stringify({ coveredBytes: this._coveredBytes }), 'utf-8');
  120. await rename(tmpPath, this._idxStatePath);
  121. }
  122. /**
  123. * Index records another process appended to the data file since our last
  124. * known coverage point. Called from refresh(). Append-only data makes this
  125. * a cheap incremental scan of just the new tail.
  126. * @param {string} dataPath - Path to the data file
  127. * @param {number} fileSize - Current data file size (from the caller's stat)
  128. */
  129. async catchUp(dataPath, fileSize) {
  130. if (this._idxStatePath === null) return; // not initialized yet
  131. if (fileSize <= this._coveredBytes) return;
  132. if (this._catchUpPromise) return this._catchUpPromise;
  133. this._catchUpPromise = (async () => {
  134. const from = this._coveredBytes;
  135. for await (const { doc, loc } of this._getDocsAndLocationsFromDataFile(dataPath, from)) {
  136. for (const field of this._indexes) {
  137. if (doc && doc[field] !== undefined) {
  138. this._deltaIndexes[field].push({ key: doc[field], loc });
  139. this._totalDeltaCount++;
  140. }
  141. }
  142. this._coveredBytes = Math.max(this._coveredBytes, loc[0] + loc[1]);
  143. }
  144. if (this._totalDeltaCount >= this._persistThreshold) {
  145. this.persist().catch(err => console.error('Index persist error:', err));
  146. }
  147. })();
  148. try {
  149. return await this._catchUpPromise;
  150. } finally {
  151. this._catchUpPromise = null;
  152. }
  153. }
  154. /**
  155. * The data file was replaced (external compaction): all offsets are invalid.
  156. * Drop delta/tombstone state and re-baseline from the compactor's rebuilt
  157. * index files, or rebuild if its state file is unreadable.
  158. * @param {string} dataPath - Path to the (new) data file
  159. */
  160. async resetAfterReplace(dataPath) {
  161. for (const field of this._indexes) {
  162. this._deltaIndexes[field] = [];
  163. this._tombstones[field].clear();
  164. }
  165. this._totalDeltaCount = 0;
  166. const state = await this._readIdxState();
  167. if (state !== null) {
  168. this._coveredBytes = state.coveredBytes || 0;
  169. } else {
  170. for (const field of this._indexes) {
  171. await this._rebuildIndex(dataPath, field);
  172. }
  173. this._coveredBytes = (await stat(dataPath).catch(() => ({ size: 0 }))).size;
  174. await this._writeIdxState();
  175. }
  176. }
  177. /**
  178. * Rebuilds an index from the data file
  179. * @param {string} dataPath - Path to the data file
  180. * @param {string} field - Field name to index
  181. * @returns {Promise<void>}
  182. * @private
  183. */
  184. async _rebuildIndex(dataPath, field) {
  185. const indexPath = this._getIndexPath(field);
  186. const tempIndexPath = `${indexPath}.${Date.now()}.tmp`;
  187. const writeStream = createWriteStream(tempIndexPath, { flags: 'w', encoding: 'utf-8' });
  188. const docIterator = this._getDocsAndLocationsFromDataFile(dataPath);
  189. const entries = [];
  190. for await (const { doc, loc } of docIterator) {
  191. if (doc && doc[field] !== undefined) {
  192. const key = doc[field];
  193. entries.push({ key, loc });
  194. }
  195. }
  196. // Sort entries before writing to the index file
  197. entries.sort((a, b) => this._compareKeys(a.key, b.key));
  198. for (const entry of entries) {
  199. writeStream.write(`${entry.key},${entry.loc[0]},${entry.loc[1]}\n`);
  200. }
  201. await new Promise(resolve => writeStream.end(resolve));
  202. await rename(tempIndexPath, indexPath);
  203. }
  204. /**
  205. * Generator that yields documents and their locations from the data file
  206. * @param {string} dataPath - Path to the data file
  207. * @yields {{doc: Object, loc: Array}} Document and location tuple
  208. * @private
  209. */
  210. async* _getDocsAndLocationsFromDataFile(dataPath, startOffset = 0) {
  211. let fileHandle;
  212. try {
  213. fileHandle = await open(dataPath, 'r');
  214. const stats = await fileHandle.stat();
  215. let offset = startOffset;
  216. while (offset < stats.size) {
  217. // Read size header
  218. const sizeBuffer = Buffer.alloc(4);
  219. await fileHandle.read(sizeBuffer, 0, 4, offset);
  220. const size = sizeBuffer.readInt32LE(0);
  221. if (size <= 0 || offset + size > stats.size) {
  222. break; // Corrupted or incomplete record
  223. }
  224. const docBuffer = Buffer.alloc(size);
  225. await fileHandle.read(docBuffer, 0, size, offset);
  226. const doc = deserialize(docBuffer.subarray(4));
  227. yield { doc, loc: [offset, size] };
  228. offset += size;
  229. }
  230. } catch (e) {
  231. if (e.code !== 'ENOENT') {
  232. throw e;
  233. }
  234. // if file does not exist, do nothing
  235. } finally {
  236. await fileHandle?.close();
  237. }
  238. }
  239. /**
  240. * Compares two keys for sorting
  241. * @param {any} keyA - First key
  242. * @param {any} keyB - Second key
  243. * @returns {number} -1 if keyA < keyB, 0 if equal, 1 if keyA > keyB
  244. * @private
  245. */
  246. _compareKeys(keyA, keyB) {
  247. if (typeof keyA === 'number' && typeof keyB === 'number') {
  248. return keyA - keyB;
  249. }
  250. return String(keyA).localeCompare(String(keyB));
  251. }
  252. /**
  253. * Starts automatic periodic persistence of indexes
  254. * @param {number} interval - Interval in milliseconds between auto-persists
  255. * @param {number} threshold - Number of operations before triggering auto-persist
  256. */
  257. startAutoPersist(interval, threshold) {
  258. this._persistThreshold = threshold;
  259. // Start periodic persist
  260. if (interval > 0) {
  261. this._persistIntervalId = setInterval(async () => {
  262. if (this._totalDeltaCount > 0) {
  263. await this.persist();
  264. }
  265. }, interval);
  266. // Don't keep process alive just for this timer
  267. this._persistIntervalId.unref();
  268. }
  269. }
  270. /**
  271. * Closes the index manager (persists indexes and clears interval)
  272. * @returns {Promise<void>}
  273. */
  274. async close() {
  275. if (this._persistIntervalId) {
  276. clearInterval(this._persistIntervalId);
  277. this._persistIntervalId = null;
  278. }
  279. await this.persist();
  280. }
  281. /**
  282. * Persists all in-memory index deltas to disk.
  283. * Runs under the db lock (via runExclusive): the read-merge-write below
  284. * would otherwise lose entries another process persisted in between.
  285. * @returns {Promise<void>}
  286. */
  287. async persist() {
  288. return this._runExclusive(() => this._persistLocked());
  289. }
  290. async _persistLocked() {
  291. for (const field of this._indexes) {
  292. let onDiskEntries = [];
  293. const indexPath = this._getIndexPath(field);
  294. try {
  295. const indexContent = await readFile(indexPath, 'utf-8');
  296. onDiskEntries = indexContent.trim().split('\n').filter(Boolean).map(line => {
  297. const [key, offset, length] = line.split(',');
  298. return { key: this._parseKey(key, field), loc: [parseInt(offset), parseInt(length)] };
  299. });
  300. } catch (e) {
  301. if (e.code !== 'ENOENT') throw e;
  302. }
  303. const fieldTombstones = this._tombstones[field];
  304. const validDiskEntries = onDiskEntries.filter(e => !fieldTombstones.has(e.loc[0]));
  305. // Dedupe by location — catch-up scans and cross-process persists can
  306. // both have picked up the same record
  307. const merged = new Map();
  308. for (const entry of [...validDiskEntries, ...(this._deltaIndexes[field] || [])]) {
  309. merged.set(`${entry.loc[0]}:${entry.loc[1]}`, entry);
  310. }
  311. const finalIndexData = Array.from(merged.values());
  312. finalIndexData.sort((a, b) => this._compareKeys(a.key, b.key));
  313. if (finalIndexData.length > 0) {
  314. const indexContent = finalIndexData.map(e => `${e.key},${e.loc[0]},${e.loc[1]}`).join('\n') + '\n';
  315. await writeFile(indexPath, indexContent, 'utf-8');
  316. } else {
  317. await writeFile(indexPath, '', 'utf-8');
  318. }
  319. // Clear the in-memory changes now that they are persisted
  320. this._deltaIndexes[field] = [];
  321. this._tombstones[field].clear();
  322. }
  323. this._totalDeltaCount = 0;
  324. // Coverage can only grow: another process's state may reach further than ours
  325. const state = await this._readIdxState();
  326. this._coveredBytes = Math.max(this._coveredBytes, state?.coveredBytes || 0);
  327. await this._writeIdxState();
  328. }
  329. /**
  330. * Parses a key based on the field's index type
  331. * @param {any} key - The key to parse
  332. * @param {string} field - Field name
  333. * @returns {any} Parsed key (number or string)
  334. * @private
  335. */
  336. _parseKey(key, field) {
  337. const indexType = this._indexTypes[field];
  338. // Use index type if specified, otherwise fall back to primary key type logic
  339. if (indexType === IndexType.NUMERIC) {
  340. const num = parseInt(key, 10);
  341. if (!isNaN(num)) return num;
  342. } else if (indexType === IndexType.LEXICAL) {
  343. return key;
  344. }
  345. // Fallback to primary key type for fields without explicit index type
  346. if (this._primaryKeyType === PrimaryKeyType.NUMBER) {
  347. const num = parseInt(key, 10);
  348. if (!isNaN(num)) return num;
  349. }
  350. return key;
  351. }
  352. _parseIndexLines(indexContent, field) {
  353. return indexContent.trim().split('\n').filter(Boolean).map(line => {
  354. const [keyStr, offset, length] = line.split(',');
  355. const locOffset = Number.parseInt(offset, 10);
  356. const locLength = Number.parseInt(length, 10);
  357. if (!Number.isSafeInteger(locOffset) || !Number.isSafeInteger(locLength) || locLength <= 0) {
  358. return null;
  359. }
  360. return {
  361. key: this._parseKey(keyStr, field),
  362. loc: [locOffset, locLength],
  363. };
  364. }).filter(Boolean);
  365. }
  366. async _readIndexEntries(field) {
  367. const indexPath = this._getIndexPath(field);
  368. try {
  369. return this._parseIndexLines(await readFile(indexPath, 'utf-8'), field);
  370. } catch (e) {
  371. if (e.code === 'ENOENT') return [];
  372. throw e;
  373. }
  374. }
  375. /**
  376. * Performs a binary search on the blocks of an index file on disk.
  377. * This is a direct port of Joshua Bloch's famously correct binary search
  378. * algorithm, adapted for file blocks.
  379. *
  380. * @param {string} field The index field to search.
  381. * @param {string|number} key The key to search for.
  382. * @returns {Promise<number>} The starting offset in the file for the linear scan.
  383. */
  384. async _binarySearchOnDisk(field, key) {
  385. return (await this._readIndexEntries(field)).length === 0 ? -1 : 0;
  386. }
  387. /**
  388. * Finds the last entry for a given field and key (checks both delta and disk)
  389. * @param {string} field - Field name
  390. * @param {any} key - Key value to search for
  391. * @returns {Promise<Object|null>} Entry object with key and loc, or null if not found
  392. */
  393. async findLastEntry(field, key) {
  394. const parsedKey = this._parseKey(key, field);
  395. const deltaIndex = this._deltaIndexes[field] || [];
  396. for (let i = deltaIndex.length - 1; i >= 0; i--) {
  397. const entry = deltaIndex[i];
  398. if (this._compareKeys(entry.key, parsedKey) === 0) {
  399. return entry;
  400. }
  401. }
  402. const onDiskEntry = await this._findLastEntryOnDisk(field, parsedKey);
  403. if (!onDiskEntry) return null;
  404. const isTombstoned = this._tombstones[field].has(onDiskEntry.loc[0]);
  405. return isTombstoned ? null : onDiskEntry;
  406. }
  407. /**
  408. * Finds the last entry for a key on disk (linear scan from binary search position)
  409. * @param {string} field - Field name
  410. * @param {any} key - Key to search for
  411. * @returns {Promise<Object|null>} Entry object or null
  412. * @private
  413. */
  414. async _findLastEntryOnDisk(field, key) {
  415. let lastMatch = null;
  416. for (const entry of await this._readIndexEntries(field)) {
  417. if (this._compareKeys(entry.key, key) === 0) {
  418. lastMatch = entry;
  419. }
  420. }
  421. return lastMatch;
  422. }
  423. /**
  424. * Adds a document to the indexes
  425. * @param {Object} doc - The document to index
  426. * @param {Array} loc - [offset, length] location in data file
  427. */
  428. insert(doc, loc) {
  429. for (const field of this._indexes) {
  430. if (doc[field] !== undefined) {
  431. this._deltaIndexes[field].push({ key: doc[field], loc });
  432. this._totalDeltaCount++;
  433. }
  434. }
  435. this._coveredBytes = Math.max(this._coveredBytes, loc[0] + loc[1]);
  436. // Auto-persist if threshold reached
  437. if (this._totalDeltaCount >= this._persistThreshold) {
  438. // Persist async without blocking
  439. this.persist().catch(err => console.error('Index persist error:', err));
  440. }
  441. }
  442. /**
  443. * Removes a document from the indexes
  444. * @param {Object} doc - The document to remove
  445. * @param {string} primaryKeyField - Primary key field name
  446. * @returns {Promise<void>}
  447. */
  448. async remove(doc, primaryKeyField) {
  449. const pkValue = doc[primaryKeyField];
  450. const deltaPkIndex = this._deltaIndexes[primaryKeyField] || [];
  451. const indexInDelta = deltaPkIndex.findIndex(e => this._compareKeys(e.key, pkValue) === 0);
  452. if (indexInDelta !== -1) {
  453. const offsetToRemove = deltaPkIndex[indexInDelta].loc[0];
  454. for (const field of this._indexes) {
  455. this._deltaIndexes[field] = (this._deltaIndexes[field] || []).filter(e => e.loc[0] !== offsetToRemove);
  456. }
  457. } else {
  458. const onDiskEntry = await this._findLastEntryOnDisk(primaryKeyField, pkValue);
  459. if (onDiskEntry) {
  460. for (const field of this._indexes) {
  461. if (doc[field] !== undefined) {
  462. this._tombstones[field].set(onDiskEntry.loc[0], true);
  463. }
  464. }
  465. }
  466. }
  467. }
  468. /**
  469. * Gets all document locations from the first index (used for full scans)
  470. * @returns {Promise<Array[]>} Array of all [offset, length] locations
  471. */
  472. async getAllLocations() {
  473. const field = this._indexes[0];
  474. const indexPath = this._getIndexPath(field);
  475. let onDiskEntries = [];
  476. try {
  477. const indexContent = await readFile(indexPath, 'utf-8');
  478. onDiskEntries = indexContent.trim().split('\n').filter(Boolean).map(line => {
  479. const [, offset, length] = line.split(',');
  480. return { loc: [parseInt(offset), parseInt(length)] };
  481. });
  482. } catch (e) {
  483. if (e.code !== 'ENOENT') throw e;
  484. }
  485. const fieldTombstones = this._tombstones[field] || new Map();
  486. const validDiskEntries = onDiskEntries.filter(e => !fieldTombstones.has(e.loc[0]));
  487. const deltaEntries = this._deltaIndexes[field] || [];
  488. const finalEntries = new Map();
  489. for (const entry of validDiskEntries) {
  490. finalEntries.set(`${entry.loc[0]}:${entry.loc[1]}`, entry.loc);
  491. }
  492. for (const entry of deltaEntries) {
  493. finalEntries.set(`${entry.loc[0]}:${entry.loc[1]}`, entry.loc);
  494. }
  495. return Array.from(finalEntries.values());
  496. }
  497. /**
  498. * Gets document locations for a given field and key
  499. * @param {string} field - Field name
  500. * @param {any} key - Key value to search for
  501. * @returns {Promise<Array[]>} Array of [offset, length] locations
  502. */
  503. async get(field, key) {
  504. const parsedKey = this._parseKey(key, field);
  505. const onDiskResults = await this._getOnDisk(field, parsedKey);
  506. const deltaResults = (this._deltaIndexes[field] || []).filter(e => this._compareKeys(e.key, parsedKey) === 0);
  507. const tombstonedOffsets = this._tombstones[field] || new Map();
  508. const finalEntries = new Map();
  509. for (const entry of onDiskResults) {
  510. if (!tombstonedOffsets.has(entry.loc[0])) {
  511. finalEntries.set(`${entry.loc[0]}:${entry.loc[1]}`, entry);
  512. }
  513. }
  514. for (const entry of deltaResults) {
  515. finalEntries.set(`${entry.loc[0]}:${entry.loc[1]}`, entry);
  516. }
  517. return Array.from(finalEntries.values());
  518. }
  519. /**
  520. * Gets all entries for a key from disk
  521. * @param {string} field - Field name
  522. * @param {any} key - Key to search for
  523. * @returns {Promise<Array>} Array of entry objects
  524. * @private
  525. */
  526. async _getOnDisk(field, key) {
  527. const results = [];
  528. for (const entry of await this._readIndexEntries(field)) {
  529. if (this._compareKeys(entry.key, key) === 0) {
  530. results.push(entry);
  531. }
  532. }
  533. return results;
  534. }
  535. /**
  536. * Yields index entries in sorted order by streaming blocks from disk.
  537. * Merges with in-memory deltas, excludes tombstones.
  538. * @param {string} field - Field name
  539. * @param {Object} [options]
  540. * @param {any} [options.from] - Start key (inclusive), uses binary search to skip ahead
  541. * @param {any} [options.to] - End key (inclusive), stops scan when exceeded
  542. * @param {'asc'|'desc'} [options.direction='asc'] - Scan direction
  543. * @yields {{key: any, loc: [number, number]}}
  544. */
  545. async *entries(field, { from, to, direction = 'asc' } = {}) {
  546. const desc = direction === 'desc';
  547. const tombstones = this._tombstones[field] || new Map();
  548. const seen = new Set();
  549. const withinRange = entry => {
  550. if (from !== undefined && this._compareKeys(entry.key, from) < 0) return false;
  551. if (to !== undefined && this._compareKeys(entry.key, to) > 0) return false;
  552. return true;
  553. };
  554. const entries = [
  555. ...(await this._readIndexEntries(field)).filter(e => !tombstones.has(e.loc[0])),
  556. ...(this._deltaIndexes[field] || []).filter(e => !tombstones.has(e.loc[0])),
  557. ]
  558. .filter(withinRange)
  559. .sort((a, b) => this._compareKeys(a.key, b.key));
  560. if (desc) entries.reverse();
  561. for (const entry of entries) {
  562. const locKey = `${entry.loc[0]}:${entry.loc[1]}`;
  563. if (seen.has(locKey)) continue;
  564. seen.add(locKey);
  565. yield entry;
  566. }
  567. }
  568. async* _getDocsByLocation(dataPath, locations) {
  569. const fileHandle = await open(dataPath, 'r');
  570. try {
  571. for (const loc of locations) {
  572. if (loc && loc.length === 2 && loc[1] > 0) {
  573. const [offset, length] = loc;
  574. const buffer = Buffer.alloc(length);
  575. await fileHandle.read(buffer, 0, length, offset);
  576. yield deserialize(buffer.subarray(4));
  577. }
  578. }
  579. } finally {
  580. await fileHandle.close();
  581. }
  582. }
  583. }
  584. export default IndexManager;

Branches

  • mastermain branch

Latest commits

  • 87888725release 1.0.7caramboleyo
  • c4cdb9b6node: import prefixes (Deno compat) + pre-existing index-state WIPcaramboleyo
  • 0afb8f4bupdate now must be a callbackcaramboleyo
  • cde73eb4release 1.0.6caramboleyo
  • d01dda02add index hints, intersection, boundingBox; remove findByIndexcaramboleyo
  • b8ffc1a0release 1.0.5caramboleyo
  • d47876a1reimplemented lost features like indexed find and more testscaramboleyo
  • 7f08da9afixed insert ignoring model definitioncaramboleyo
  • 705774a9added flush before findcaramboleyo
  • b4db6391initial commitcaramboleyo