import {
RecordAccessError,
RecordParseError,
RecordDoesNotExist,
} from "../../errors.js";
import { ModelStore } from "./model-store.js";
import { equals } from "../../equals/equals.js";
import * as basicSchema from "../../schema/basic-js-schema.js";
import * as migrations from "../../migration/make-migration.js";
let fs, path;
/**
* A simple storage backend for use with Node.js, using the filesystem.
* @extends ModelStore
*/
export class FileSystemStore extends ModelStore {
// We need a bootstrap so that the fs/path imports
// don't cause problems for browser bundles.
static async bootstrap() {
fs = await import("fs");
path = await import("path");
}
/**
*
* @param {*} path
*/
constructor(path) {
super();
fs.mkdirSync(path, { recursive: true });
this.storePath = path;
}
/**
*
* @returns {boolean} True if the file system store is ready to read/write files.
*/
ready() {
return !!this.storePath;
}
/**
* Load a model record's data from the model schema's data directory
* @param {*} schema
* @param {*} recordName
* @returns {object} A plain JS data object
*/
async loadRecord(schema, recordName) {
let fileData = undefined;
const filepath = `${this.storePath}/${schema.__meta.name}/${recordName}.json`;
// Can we read this file?
try {
fileData = await fs.promises.readFile(filepath);
} catch (e) {
throw new RecordAccessError(filepath);
}
// Can we *parse* this file?
try {
fileData = JSON.parse(fileData);
} catch (e) {
throw new RecordParseError(filepath);
}
return fileData;
}
/**
* Save a model instance to the model schema's data directory.
* @param {*} schema
* @param {*} instance
* @param {*} recordName
*/
async saveRecord(schema, instance, recordName) {
const filepath = `${this.storePath}/${schema.__meta.name}/${recordName}.json`;
await fs.promises.writeFile(filepath, instance.toString());
}
/**
* Delete a model instance from the model schema's data directory.
* @param {*} schema
* @param {*} recordName
*/
async deleteRecord(schema, recordName) {
const filepath = `${this.storePath}/${schema.__meta.name}/${recordName}.json`;
if (!fs.existsSync(filepath)) {
throw new RecordDoesNotExist(filepath);
}
try {
return fs.promises.unlink(filepath);
} catch (err) {
throw new RecordAccessError(filepath);
}
}
/**
* Save a model's schema to disk
* @param {*} Model
*/
async saveSchema(schema) {
// how many distinct schema are we actually working with?
const schemaSet = basicSchema.unlinkSchema(schema);
// generate new files for the updated schema.
while (schemaSet.length) {
const { schema, __meta } = schemaSet.shift();
schema.__meta = __meta;
// Make sure we have a dir to write to
const dir = `${this.storePath}/${__meta.name}/.schema`;
fs.mkdirSync(dir, { recursive: true });
// Is this (sub)schema the same as the previous version?
// Because it's possible a change in the overall schema does
// not actually change anything in a distinct subschema, or vice
// versa, and there's no point in a new, but "the same", file.
let newVersion = 1;
const stored = this.getLatestSchemaFilePath(dir, __meta.name);
if (stored.version) {
newVersion = stored.version + 1;
const { filepath } = stored;
const s1 = fs.readFileSync(filepath).toString();
const s2 = schema.toString();
if (equals(s1, s2)) return;
}
// This is not the same data as the previously stored version.
const newschemafile = `${__meta.name}.${newVersion}.json`;
const newfilepath = `${dir}/${newschemafile}`;
// Write the schema to file, iF it doesn't already exist, because
// linked schema might already have a stored schema file.
if (!fs.existsSync(newfilepath)) {
fs.writeFileSync(newfilepath, schema.toString());
}
}
}
/**
* Load a schema by name, automatically making sure to load the latest
* version if there are multiple versions in the schema's `.schema` dir.
* @param {*} schema
* @returns {schema} The latest version of this schema as found in the file system.
*/
async loadSchema(schema) {
return this.getLatestSchema(schema.__meta.name);
}
/**
* Get the latest schema, loaded from the relevant schema dir.
* @param {*} dir
* @param {*} schemaName
* @returns {schema} The latest version of this schema as found in the file system.
* @ignore
*/
getLatestSchema(schemaName) {
const dir = `${this.storePath}/${schemaName}/.schema/`;
if (!fs.existsSync(dir)) return;
const { filepath, version } = this.getLatestSchemaFilePath(dir, schemaName);
// this is a weird error: it implies the schema dir exists,
// but it has no content...
if (!fs.existsSync(filepath)) {
console.warn(`"${dir}" exists, but has no schema files in it?`);
return;
}
const loadedSchema = this.loadSchemaFromFilePath(filepath);
Object.defineProperty(loadedSchema.__meta, `version`, {
configurable: false,
enumerable: false,
value: version,
});
return loadedSchema;
}
/**
* determine the latest schema file, based on version number
* @param {*} dir
* @param {*} schemaName
* @returns {filepath} The file path for the latest version of the named schema as found in the file system.
* @ignore
*/
getLatestSchemaFilePath(dir, schemaName) {
const re = new RegExp(`${schemaName}\\.(\\d+)\\.json`);
const versions = fs
.readdirSync(dir)
.filter((n) => n.match(re))
.map((n) => {
const s = n.replace(re, `$1`);
return parseInt(s);
});
const version = versions.sort((a, b) => b - a)[0];
return {
filepath: `${dir}/${schemaName}.${version}.json`,
version,
};
}
/**
* @param {*} schemaPath
* @returns {schema} a schema object
* @ignore
*/
loadSchemaFromFilePath(schemaPath) {
if (!fs.existsSync(schemaPath)) {
throw new RecordDoesNotExist(schemaPath);
}
const data = fs.readFileSync(schemaPath);
// the following two lines may throw:
const parsed = JSON.parse(data);
basicSchema.linkSchema(parsed, (...args) => this.getLatestSchema(...args));
return parsed;
}
/**
* ...
* @param {*} schema1
* @param {*} schema2
* @param {*} migration
*/
async saveMigration(schema1, schema2, migration) {
const from = schema1.__meta.version;
const to = from + 1;
const filename = `${schema2.__meta.name}.v${from}.to.v${to}.js`;
let filepath = `${this.storePath}/${schema2.__meta.name}/${filename}`;
filepath = path.posix.normalize(filepath);
const script = migrations.finalizeMigration(migration);
fs.writeFileSync(filepath, script, `utf-8`);
// announce this migration file
const msg = ` Migration saved, run using "node ${filepath}" `;
const line = `═`.repeat(msg.length);
[`╔${line}╗`, `║${msg}║`, `╚${line}╝`].forEach((l) => console.log(l));
}
}