diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff9abea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main, "feature/**"] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + node: [20, 22] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm install --ignore-scripts + - run: npm run ci + - run: npm pack --dry-run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48dfd1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +coverage/ +*.RedXai.bak +*.RedXai.lock +*.RedXai.tmp-* +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a823e46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 RedXDevelopment + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index b1540b3..9bc5aee 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,99 @@ # RedXaiHM -RedXaiHM is RedXDevelopment's ID-centric, human-readable database language and long-term storage engine. It stores durable application data in `.RedXai` files and is designed around stable numeric identities, strict validation, transactional writes, and official SDK access. +RedXaiHM is RedXDevelopment's ID-centric database language and long-term storage engine. It stores durable application data in files with the exact `.RedXai` extension and is intended for accounts, permissions, API keys, OAuth records, settings, billing state, and other important data that must survive for years. -> Status: early development. The language and storage format are not yet production-stable. +> **Development status:** `0.1.0-alpha.1`. The parser, validator, storage engine, SDK surface, CLI, and first editor are functional, but the format is not yet production-stable and encryption is intentionally reserved for a later RedX Crypt milestone. -## Core design +## What makes it RedXaiHM -- Every database begins with a required `RedXaiStore` header whose ID is `1`. -- IDs are unique inside a database; `0` is never valid. -- Values may omit IDs and remain local to their containing structure. -- `NELL` is the explicit unassigned/non-existent value. -- Arrays may contain values, variables, and nested arrays. -- Access points can restrict SDK languages and external projects. -- `.RedXai` is the official file extension. +- Every database begins with `{RedXaiStore[ID=1 ...]=[`. +- `0` is never a valid ID. `1` is reserved for the database header. +- IDs on values are optional, but every supplied ID must be unique across the document/project. +- `NELL`, in any letter case, is the explicit unassigned/non-existent value. +- Arrays use the RedXai form `;[ ... ]:` and may contain values, variables, comments, and nested arrays. +- Variables inside arrays remain addressable by ID anywhere in the database scope. +- Access points can pair an API key with allowed language extensions. +- Shared-project and global-access metadata are built into the database closing block. +- The engine supplies fast ID lookup, rename, edit, copy, move, delete, transactions, atomic saves, backups, and stale-lock recovery. -## First milestone +## Canonical example -The initial TypeScript reference implementation includes the lexer, parser, AST, validator, serializer, in-memory ID index, transactional file store, command-line interface, examples, and tests. +```redxai +{RedXaiStore[ID=1, Version="0.1"]=[ + << Access points are declared before normal stored values. + {MailAccess[ID=5032]} = {"RedX - replace-this-key", ".js", ".py", ".cs"}, + + {Accounts[ID=10]} = ;[ + {Account[ID=11]} = ;[ + {Email[ID=12]} = "owner@redxaimail.com", + {Verified[ID=13]} = TRUE, + {RecoveryEmail[ID=14]} = NELL + ]: + ]: +] Metadata={ + Shared=FALSE, + ShareID=NELL, + OPFNames=NELL, + GA=FALSE, + GlobalID=NELL +}}; +``` + +The parser also accepts the original positional closing form: + +```redxai +]FALSE, NELL, NELL, FALSE, NELL}; +``` + +The serializer always writes the clearer named metadata form. + +## Install and use + +RedXaiHM currently has no runtime dependencies and requires Node.js 20.11 or newer. + +```bash +npm install +npm test +node ./src/cli.js validate ./examples/accounts.RedXai +node ./src/cli.js edit ./examples/accounts.RedXai +``` + +SDK example: + +```js +import { RedXaiFileStore } from '@redxdevelopment/redxaihm'; + +const store = new RedXaiFileStore(); +const loaded = await store.load('./Accounts.RedXai'); + +loaded.database.transaction((db) => { + db.setById(13, true); + db.renameById(14, 'BackupEmail'); +}); + +await store.save('./Accounts.RedXai', loaded.database.document); +``` + +## Official editing path + +`.RedXai` files are associated with the RedXaiHM Editor through `scripts/register-windows.ps1`. The initial editor runs locally on `127.0.0.1`, validates every save through the reference parser, and writes through the atomic storage engine. The operating system cannot make a text-based format mathematically impossible to open in another program; future encrypted containers will make unauthorized direct editing impractical. Until then, the supported write paths are the editor, CLI, and SDK. + +## Repository map + +- `src/lexer.js` — comments, strings, numbers, symbols, punctuation. +- `src/parser.js` — source to AST, including legacy metadata compatibility. +- `src/validator.js` — ID, metadata, access-point, reference, and project rules. +- `src/serializer.js` — canonical `.RedXai` source writer. +- `src/database.js` — indexed operations and transactions. +- `src/store.js` — locks, backups, atomic writes, and recovery. +- `src/editor-server.js` and `editor/` — official local editor. +- `docs/SPECIFICATION.md` — language rules. +- `test/` — reference behavior tests. + +## Security boundary + +This alpha does **not** invent or claim custom encryption. Passwords must be stored as strong password hashes, and sensitive fields should remain protected by the application until RedX Crypt adds authenticated encryption based on reviewed cryptographic primitives. ## License -Apache-2.0. See `LICENSE`. +Apache-2.0. diff --git a/assets/redxai-file-icon.svg b/assets/redxai-file-icon.svg new file mode 100644 index 0000000..6b34080 --- /dev/null +++ b/assets/redxai-file-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..79b7a60 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,41 @@ +# RedXaiHM Roadmap + +## 0.1 — reference core + +- Language specification +- Lexer, parser, AST, and canonical serializer +- Original positional metadata compatibility +- ID and metadata validation +- In-memory ID index +- Find, edit, rename, move, copy, and delete operations +- Transactions +- Atomic file storage, locks, backups, and recovery +- CLI and local official editor +- Windows `.RedXai` association and Red X icon +- Node.js SDK + +## 0.2 — schemas and migrations + +- Optional schemas +- Required and constrained fields +- Unique indexes beyond IDs +- Version migration declarations +- Query filters and pagination +- Transaction journal and recovery tests +- Performance benchmark suite + +## 0.3 — identity service integration + +- RedX Identity service adapter +- Migration tooling from the existing SQLite account store +- Dual-write and verification period before cutover +- Session, OAuth, API-key, and audit databases +- Production backup and restore exercises + +## Later — RedX Crypt + +- Authenticated encrypted `.RedXai` containers +- Key rotation and per-field encryption +- Recovery keys +- Integrity signatures +- Secret-redaction support in the editor and CLI diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..d2f4157 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,19 @@ +# RedXaiHM Security Model — Alpha + +RedXaiHM 0.1 provides structural validation, atomic writes, lock files, backups, strict file extensions, and safe official editing paths. It does not yet provide encryption, signatures, access-control enforcement, or network authentication. + +## Account data rules + +- Never store plaintext passwords. Store a password hash generated by the identity service with a modern password-hashing function and per-user salt. +- Do not store raw payment card data. +- API keys, OAuth secrets, mailbox credentials, recovery tokens, and session material must remain encrypted by the application until RedX Crypt is available. +- A value being omitted from global ID access does not make it secret; it only makes it local to the database structure. +- Language-extension restrictions on access points are descriptive policy and must be enforced by the service issuing SDK credentials. + +## File permissions + +The file store creates new database files and temporary files with owner-only mode `0600` on systems that honor POSIX permissions. Deployments should keep database directories outside public web roots and restrict the operating-system account that can read them. + +## Cryptography roadmap + +RedX Crypt will use reviewed, standard authenticated-encryption and key-derivation primitives. RedXDevelopment may define its own envelope, key hierarchy, rotation policy, metadata, and developer experience, but will not invent an unreviewed cipher and represent it as secure. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..07b0802 --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,247 @@ +# RedXaiHM Language Specification — Draft 0.1 + +This document defines the behavior implemented by the JavaScript reference engine. It refines the original RedXaiHM design without replacing its core syntax or identity rules. + +## 1. Files + +A RedXaiHM database uses the exact extension `.RedXai`. Files are UTF-8 in version 0.1. Official tools must refuse to save another extension. + +A document contains one or more databases. Every database begins with a `RedXaiStore` header. The first non-whitespace token of a document must begin a database header. + +## 2. Database header + +Canonical form: + +```redxai +{RedXaiStore[ID=1, Version="0.1"]=[ +``` + +Rules: + +1. The header name and casing must be exactly `RedXaiStore`. +2. Every database header ID is exactly `1`. +3. ID `0` is invalid everywhere. +4. Header ID `1` may repeat only because each database requires it. +5. `Version` is optional while parsing and defaults to `0.1`; the serializer always writes it. + +## 3. Variables and IDs + +Canonical variable form: + +```redxai +{Name[ID=25]} = "value" +``` + +Accepted compact forms: + +```redxai +{Name[25]} = "value" +{Name[]} = "local value" +``` + +An omitted ID makes the variable local and not directly available through global ID APIs. A supplied ID must be an integer greater than `1` and unique regardless of whether the value is a string, number, Boolean, array, access point, or another type. + +The reference validator checks uniqueness across every database in one document. `validateProject()` checks multiple documents and is the foundation for a future project-wide ID catalog. + +## 4. Values + +### 4.1 Boolean + +All of the following parse identically: + +```redxai +TRUE +True +true +FALSE +False +false +``` + +Canonical output uses `TRUE` and `FALSE`. + +### 4.2 Number + +Integers, decimals, negative values, and scientific notation are supported as one numeric family: + +```redxai +25 +26.8 +-4 +1.5e6 +``` + +### 4.3 Percentage + +A number immediately followed by `%` is represented as the distinct `percent` value type while retaining its written amount: + +```redxai +20% +``` + +### 4.4 String + +Strings use double quotes and JSON-compatible escapes: + +```redxai +"this is a string" +"line one\nline two" +``` + +### 4.5 NELL + +`NELL` is RedXaiHM's explicit non-existent or not-yet-assigned value. `NELL`, `Nell`, `nell`, and the original `NELL$` spelling are accepted. Canonical output is `NELL`. + +### 4.6 Symbol + +A bare identifier used as a value is a symbol: + +```redxai +DB23 +``` + +Strings are preferred for secrets, filenames, and arbitrary text. + +### 4.7 Reference + +References point to an ID or name: + +```redxai +@25 +@PrimaryAccount +``` + +Unresolved references are validation errors. + +## 5. Arrays + +Arrays use the original RedXaiHM delimiters: + +```redxai +{Players[ID=5]} = ;[ + "Player One", + 4, + TRUE, + NELL, + ;["nested", FALSE]: +]: +``` + +Arrays can contain scalar values, collections, nested arrays, comments, and complete variables. A variable inside an array with an ID is indexed at database scope and can be reached directly through SDK operations. + +```redxai +{Accounts[ID=10]} = ;[ + {Email[ID=11]} = "owner@example.com" +]: +``` + +## 6. Collections and access points + +Collections use braces: + +```redxai +{"Game1", "Game2", "Game3"} +``` + +An assignment is recognized as an access point when its collection contains an API-key string followed by one or more language-extension strings: + +```redxai +{AnyVariableName[ID=5032]} = { + "RedX - 0647gd6j4496hjt", + ".js", + ".py", + ".cs", + ".cpp", + ".c", + ".lua" +} +``` + +Access points must appear before normal stored values. Their names are not reserved. In version 0.1, API keys are required to be quoted, and extension entries must begin with a period. + +Language restrictions are policy metadata. Applications and SDK gateways are responsible for enforcing them; a file extension alone is not a security boundary. + +## 7. Comments + +Single-line comment: + +```redxai +<< comment text +``` + +Multiline comment: + +```redxai +<< +comment line one +comment line two +>> +``` + +For compatibility with the original examples, a `<<` comment containing text may continue until `>>` when no newer `<<` begins first. Comments are retained in the AST and canonical serialization. + +## 8. Database metadata + +Canonical closing block: + +```redxai +] Metadata={ + Shared=TRUE, + ShareID=508173, + OPFNames={"Game1", "Game2", "Game3"}, + GA=TRUE, + GlobalID=23001 +}}; +``` + +Original positional closing syntax is accepted: + +```redxai +]TRUE, 508173, OPFNames={"Game1", "Game2"}, GA=TRUE, ID=23001}; +``` + +Private database: + +```redxai +]FALSE, NELL, NELL, FALSE, NELL}; +``` + +Rules: + +- `Shared=TRUE` requires a non-`NELL` share ID and at least one allowed project filename. +- `GA=TRUE` requires a numeric global ID greater than `1`. +- Global APIs expose only values that carry IDs. Local values without IDs remain unavailable through global ID lookup. +- A future project catalog will enforce unique share IDs and global IDs across separate repositories and services. + +## 9. Reading order and scope + +Within a database, entries are read from top to bottom. Variables with IDs inside arrays are indexed at database scope. Across a document, globally accessible databases are intended to be indexed before private databases by higher-level runtimes; the 0.1 parser preserves source order and leaves policy ordering to the application layer. + +## 10. Transactions and storage + +The reference file store: + +1. Parses and validates before writing. +2. Acquires an exclusive lock file. +3. Copies the current database to `.bak` when backups are enabled. +4. Writes a same-directory temporary file with mode `0600`. +5. Flushes the file. +6. Atomically renames it over the target. +7. Attempts to flush the containing directory. +8. Removes the lock. + +If the primary file fails to load, recovery may use the validated `.bak` file. This mechanism protects against interrupted writes; it is not encryption or tamper-proof authentication. + +## 11. Canonical operations + +The first SDK exposes ID-focused operations matching the original design: + +- `getById(id)` +- `setById(id, value)` +- `renameById(id, name)` +- `deleteById(id)` +- `copyById(id, options)` +- `moveById(id, targetArrayId, index)` +- `transaction(callback)` + +The editor and all future SDKs must produce behavior equivalent to the reference implementation. diff --git a/editor/app.js b/editor/app.js new file mode 100644 index 0000000..86e3090 --- /dev/null +++ b/editor/app.js @@ -0,0 +1,114 @@ +const params = new URLSearchParams(location.search); +const token = params.get('token'); +const source = document.querySelector('#source'); +const issues = document.querySelector('#issues'); +const state = document.querySelector('#state'); +const fileName = document.querySelector('#file-name'); +const validateButton = document.querySelector('#validate'); +const formatButton = document.querySelector('#format'); +const saveButton = document.querySelector('#save'); +let dirty = false; + +await load(); + +source.addEventListener('input', () => { + dirty = true; + setState('Unsaved changes', 'warn'); +}); + +validateButton.addEventListener('click', () => runValidation(false)); +formatButton.addEventListener('click', async () => { + const result = await runValidation(false); + if (result?.formatted) { + source.value = result.formatted; + dirty = true; + setState('Formatted, not saved', 'warn'); + } +}); +saveButton.addEventListener('click', save); +window.addEventListener('beforeunload', (event) => { + if (dirty) event.preventDefault(); +}); + +async function load() { + try { + const response = await fetch(`/api/document?token=${encodeURIComponent(token)}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to load'); + fileName.textContent = data.fileName; + source.value = data.source; + dirty = false; + renderIssues(data.warnings || []); + setState(data.recoveredFrom ? 'Recovered from backup' : 'Ready', data.recoveredFrom ? 'warn' : 'ok'); + } catch (error) { + renderError(error); + } +} + +async function runValidation(showSuccess = true) { + setState('Validating…'); + try { + const response = await fetch(`/api/validate?token=${encodeURIComponent(token)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: source.value }), + }); + const data = await response.json(); + if (!response.ok) throw Object.assign(new Error(data.error || 'Validation failed'), { issues: data.issues }); + renderIssues(data.issues || []); + const errors = (data.issues || []).filter((item) => item.severity === 'error'); + setState(errors.length ? `${errors.length} error(s)` : showSuccess ? 'Valid' : 'Ready', errors.length ? 'bad' : 'ok'); + return data; + } catch (error) { + renderError(error); + return null; + } +} + +async function save() { + setState('Saving…'); + try { + const response = await fetch(`/api/save?token=${encodeURIComponent(token)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: source.value }), + }); + const data = await response.json(); + if (!response.ok) throw Object.assign(new Error(data.error || 'Save failed'), { issues: data.issues }); + source.value = data.source; + dirty = false; + renderIssues(data.issues || []); + setState('Saved', 'ok'); + } catch (error) { + renderError(error); + } +} + +function renderIssues(list) { + if (!list.length) { + issues.innerHTML = '

No issues found.

'; + return; + } + issues.replaceChildren(...list.map((item) => { + const box = document.createElement('article'); + box.className = `issue ${item.severity}`; + const title = document.createElement('strong'); + title.textContent = `${item.code} · ${item.severity}`; + const path = document.createElement('code'); + path.textContent = item.path; + const message = document.createElement('p'); + message.textContent = item.message; + box.append(title, path, message); + return box; + })); +} + +function renderError(error) { + renderIssues(error.issues || [{ code: 'EDITOR', severity: 'error', path: '$', message: error.message }]); + setState('Error', 'bad'); +} + +function setState(text, style = '') { + state.textContent = text; + state.className = `state ${style}`; +} diff --git a/editor/index.html b/editor/index.html new file mode 100644 index 0000000..c024cad --- /dev/null +++ b/editor/index.html @@ -0,0 +1,43 @@ + + + + + + RedXaiHM Editor + + + + +
+ Red X +
+

RedXaiHM Editor

+

Loading database…

+
+
+ + + +
+
+ +
+
+
+ Official .RedXai source editor + Loading +
+ +
+ +
+ + + diff --git a/editor/styles.css b/editor/styles.css new file mode 100644 index 0000000..b6a1e3a --- /dev/null +++ b/editor/styles.css @@ -0,0 +1,41 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #07070a; + color: #f5f2f7; +} +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #240414 0, #09090d 42%, #050507 100%); } +header { min-height: 82px; display: flex; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid #4c1731; background: rgba(8,8,12,.9); backdrop-filter: blur(16px); } +header img { width: 50px; height: 50px; filter: drop-shadow(0 0 14px rgba(255,0,72,.55)); } +h1 { margin: 0; font-size: 1.25rem; } +header p { margin: 4px 0 0; color: #ad9dad; } +.actions { margin-left: auto; display: flex; gap: 10px; } +button { border: 1px solid #5c2840; background: #171017; color: white; border-radius: 9px; padding: 10px 16px; font-weight: 700; cursor: pointer; } +button:hover { border-color: #e12b65; transform: translateY(-1px); } +button.primary { background: linear-gradient(135deg, #b60038, #660024); border-color: #ff245f; box-shadow: 0 0 18px rgba(255,0,72,.22); } +main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: calc(100vh - 82px); } +.workspace { min-width: 0; display: flex; flex-direction: column; } +.bar { display: flex; justify-content: space-between; align-items: center; min-height: 44px; padding: 8px 16px; border-bottom: 1px solid #34202b; background: #0e0d12; color: #c6b9c5; } +.state { border: 1px solid #51434d; border-radius: 999px; padding: 5px 10px; font-size: .8rem; } +.state.ok { color: #89f0b0; border-color: #1a7145; } +.state.warn { color: #ffd579; border-color: #88631a; } +.state.bad { color: #ff849e; border-color: #9b2344; } +textarea { flex: 1; width: 100%; min-height: 540px; resize: none; border: 0; outline: 0; padding: 22px; background: rgba(4,4,7,.94); color: #f8eefa; font: 15px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; tab-size: 2; } +aside { border-left: 1px solid #4c1731; padding: 20px; background: rgba(11,9,14,.96); overflow: auto; } +aside h2 { margin-top: 0; } +.issues { display: grid; gap: 10px; } +.issue { border: 1px solid #513341; border-left-width: 4px; border-radius: 8px; padding: 10px; background: #121015; } +.issue.error { border-left-color: #ff245f; } +.issue.warning { border-left-color: #ffb72c; } +.issue code { display: block; margin-top: 5px; color: #c79fb5; overflow-wrap: anywhere; } +.issue p { margin: 8px 0 0; color: #ded2dc; } +.success { color: #8ce8af; } +.note { margin-top: 22px; padding: 14px; border: 1px solid #4d2038; border-radius: 10px; background: linear-gradient(150deg, rgba(124,0,43,.2), rgba(42,11,46,.2)); } +.note p { color: #c6b7c4; margin-bottom: 0; line-height: 1.5; } +@media (max-width: 900px) { + header { flex-wrap: wrap; } + .actions { width: 100%; margin-left: 0; } + main { grid-template-columns: 1fr; } + aside { border-left: 0; border-top: 1px solid #4c1731; } +} diff --git a/examples/accounts.RedXai b/examples/accounts.RedXai new file mode 100644 index 0000000..78ea925 --- /dev/null +++ b/examples/accounts.RedXai @@ -0,0 +1,23 @@ +{RedXaiStore[ID=1, Version="0.1"]=[ + << Access points must appear before normal stored values. + {MailPlatformAccess[ID=5032]} = {"RedX - replace-this-development-key", ".js", ".py", ".cs", ".cpp", ".lua"}, + + << Long-lived RedX identity account data. + {Accounts[ID=10]} = ;[ + {Account[ID=11]} = ;[ + {AccountID[ID=12]} = "rx_user_01", + {Email[ID=13]} = "owner@redxaimail.com", + {PasswordHash[ID=14]} = "replace-with-a-real-password-hash", + {Verified[ID=15]} = TRUE, + {DisplayName[ID=16]} = "MainDirectory", + {RecoveryEmail[ID=17]} = NELL, + {StorageUsed[ID=18]} = 20% + ]: + ]: +] Metadata={ + Shared=FALSE, + ShareID=NELL, + OPFNames=NELL, + GA=FALSE, + GlobalID=NELL +}}; diff --git a/examples/legacy-syntax.RedXai b/examples/legacy-syntax.RedXai new file mode 100644 index 0000000..18a161f --- /dev/null +++ b/examples/legacy-syntax.RedXai @@ -0,0 +1,3 @@ +{RedXaiStore[ID=1]=[ + {PlayersArray[ID=5]} = ;["Player One", 4, TRUE, FALSE, NELL]: +]FALSE, NELL, NELL, FALSE, NELL}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1b5a0d1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "@redxdevelopment/redxaihm", + "version": "0.1.0-alpha.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@redxdevelopment/redxaihm", + "version": "0.1.0-alpha.1", + "license": "Apache-2.0", + "bin": { + "redxai": "src/cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b0de33 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@redxdevelopment/redxaihm", + "version": "0.1.0-alpha.1", + "description": "RedXaiHM ID-centric long-term database language, parser, storage engine, CLI, and editor.", + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js", + "./parser": "./src/parser.js", + "./store": "./src/store.js" + }, + "bin": { + "redxai": "./src/cli.js" + }, + "files": [ + "src", + "editor", + "assets", + "scripts", + "docs", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node --test", + "check": "node --check src/*.js && node --check editor/*.js", + "ci": "npm run check && npm test" + }, + "engines": { + "node": ">=20.11" + }, + "keywords": [ + "database", + "redxai", + "storage", + "parser", + "transactional" + ], + "license": "Apache-2.0" +} diff --git a/scripts/register-windows.ps1 b/scripts/register-windows.ps1 new file mode 100644 index 0000000..4cf69fb --- /dev/null +++ b/scripts/register-windows.ps1 @@ -0,0 +1,58 @@ +param( + [string]$NodePath = (Get-Command node).Source, + [string]$CliPath = (Resolve-Path "$PSScriptRoot\..\src\cli.js").Path, + [string]$IconPath = "$PSScriptRoot\..\assets\redxai-file-icon.ico" +) + +$ErrorActionPreference = "Stop" + +function New-RedXIcon([string]$Destination) { + Add-Type -AssemblyName System.Drawing + $bitmap = New-Object System.Drawing.Bitmap 256, 256 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $graphics.Clear([System.Drawing.Color]::Transparent) + $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( + (New-Object System.Drawing.Rectangle 0, 0, 256, 256), + ([System.Drawing.Color]::FromArgb(255, 255, 23, 79)), + ([System.Drawing.Color]::FromArgb(255, 112, 0, 31)), + 45 + ) + $pen = New-Object System.Drawing.Pen $brush, 48 + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round + $graphics.DrawLine($pen, 48, 48, 208, 208) + $graphics.DrawLine($pen, 208, 48, 48, 208) + $iconHandle = $bitmap.GetHicon() + $icon = [System.Drawing.Icon]::FromHandle($iconHandle).Clone() + $stream = [IO.File]::Create($Destination) + try { $icon.Save($stream) } finally { + $stream.Dispose() + $icon.Dispose() + $pen.Dispose() + $brush.Dispose() + $graphics.Dispose() + $bitmap.Dispose() + } +} + +if (-not (Test-Path $IconPath)) { + New-RedXIcon -Destination $IconPath +} + +$IconPath = (Resolve-Path $IconPath).Path +$classes = "HKCU:\Software\Classes" +New-Item -Path "$classes\.RedXai" -Force | Out-Null +Set-ItemProperty -Path "$classes\.RedXai" -Name "(default)" -Value "RedXaiHM.Database" + +New-Item -Path "$classes\RedXaiHM.Database" -Force | Out-Null +Set-ItemProperty -Path "$classes\RedXaiHM.Database" -Name "(default)" -Value "RedXaiHM Database" + +New-Item -Path "$classes\RedXaiHM.Database\DefaultIcon" -Force | Out-Null +Set-ItemProperty -Path "$classes\RedXaiHM.Database\DefaultIcon" -Name "(default)" -Value $IconPath + +New-Item -Path "$classes\RedXaiHM.Database\shell\open\command" -Force | Out-Null +$command = ('"{0}" "{1}" edit "%1"' -f $NodePath, $CliPath) +Set-ItemProperty -Path "$classes\RedXaiHM.Database\shell\open\command" -Name "(default)" -Value $command + +Write-Host ".RedXai files are now associated with RedXaiHM Editor." diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..616c31e --- /dev/null +++ b/src/cli.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { parse, parseValue } from './parser.js'; +import { serialize } from './serializer.js'; +import { validate } from './validator.js'; +import { RedXaiFileStore } from './store.js'; +import { createDocument, toPlainValue } from './model.js'; +import { launchEditor } from './editor-server.js'; + +const [, , command, ...args] = process.argv; +const store = new RedXaiFileStore(); + +try { + switch (command) { + case 'init': await init(args); break; + case 'validate': await validateCommand(args); break; + case 'format': await format(args); break; + case 'get': await get(args); break; + case 'set': await set(args); break; + case 'delete': await remove(args); break; + case 'edit': await edit(args); break; + case 'help': + case undefined: printHelp(); break; + default: throw new Error(`Unknown command ${command}`); + } +} catch (error) { + console.error(`RedXaiHM: ${error.message}`); + if (error.issues) { + for (const current of error.issues) console.error(` ${current.code} ${current.path}: ${current.message}`); + } + process.exitCode = 1; +} + +async function init([file]) { + requireArg(file, 'init requires a .RedXai file path'); + const path = resolve(file); + await store.save(path, createDocument()); + console.log(`Created ${path}`); +} + +async function validateCommand([file]) { + requireArg(file, 'validate requires a .RedXai file path'); + const source = await readFile(resolve(file), 'utf8'); + const document = parse(source); + const issues = validate(document); + for (const current of issues) console.log(`${current.severity.toUpperCase()} ${current.code} ${current.path}: ${current.message}`); + if (issues.some((current) => current.severity === 'error')) process.exitCode = 1; + else console.log(`Valid RedXaiHM database${issues.length ? ` with ${issues.length} warning(s)` : ''}.`); +} + +async function format([file, flag]) { + requireArg(file, 'format requires a .RedXai file path'); + const path = resolve(file); + const document = parse(await readFile(path, 'utf8')); + const output = serialize(document); + if (flag === '--write') { + await writeFile(path, output, 'utf8'); + console.log(`Formatted ${path}`); + } else { + process.stdout.write(output); + } +} + +async function get([file, idText]) { + requireArg(file && idText, 'get requires a file and numeric ID'); + const loaded = await store.load(resolve(file)); + const node = loaded.database.requireById(parseId(idText)); + console.log(JSON.stringify({ id: node.id, name: node.name, value: toPlainValue(node.value) }, null, 2)); +} + +async function set([file, idText, ...literalParts]) { + requireArg(file && idText && literalParts.length, 'set requires a file, numeric ID, and RedXai value'); + const path = resolve(file); + const value = parseValue(literalParts.join(' ')); + await store.transaction(path, (database) => database.setById(parseId(idText), value)); + console.log(`Updated ID ${idText} in ${path}`); +} + +async function remove([file, idText]) { + requireArg(file && idText, 'delete requires a file and numeric ID'); + const path = resolve(file); + const deleted = await store.transaction(path, (database) => database.deleteById(parseId(idText))); + if (!deleted) throw new Error(`ID ${idText} was not found`); + console.log(`Deleted ID ${idText} from ${path}`); +} + +function parseId(value) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 1) throw new Error('ID must be an integer greater than 1'); + return id; +} + +function requireArg(value, message) { + if (!value) throw new Error(message); +} + +async function edit([file]) { + requireArg(file, 'edit requires a .RedXai file path'); + const launched = await launchEditor(resolve(file)); + console.log(`RedXaiHM Editor: ${launched.url}`); +} + +function printHelp() { + console.log(`RedXaiHM CLI\n\nCommands:\n redxai init \n redxai validate \n redxai format [--write]\n redxai get \n redxai set \n redxai delete \n redxai edit `); +} diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..28de58b --- /dev/null +++ b/src/database.js @@ -0,0 +1,190 @@ +import { assertValid } from './validator.js'; +import { valueFrom, assignment as createAssignment } from './model.js'; + +export class RedXaiDatabase { + constructor(document, options = {}) { + this.document = structuredClone(document); + this.options = { validate: options.validate !== false }; + if (this.options.validate) assertValid(this.document); + this.rebuildIndex(); + } + + rebuildIndex() { + this.idIndex = new Map(); + this.nameIndex = new Map(); + this.containers = []; + this.document.databases.forEach((database, databaseIndex) => { + this.indexContainer(database.entries, { kind: 'database', databaseIndex, items: database.entries }); + }); + } + + indexContainer(items, container) { + this.containers.push(container); + items.forEach((item, index) => { + if (item.kind !== 'assignment') return; + const record = { node: item, container: items, index, databaseIndex: container.databaseIndex }; + if (item.id != null) this.idIndex.set(item.id, record); + const key = item.name.toLowerCase(); + const list = this.nameIndex.get(key) ?? []; + list.push(record); + this.nameIndex.set(key, list); + if (item.value.valueType === 'array') { + this.indexContainer(item.value.items, { + kind: 'array', + databaseIndex: container.databaseIndex, + owner: item, + items: item.value.items, + }); + } + }); + } + + getById(id) { + return this.idIndex.get(id)?.node ?? null; + } + + requireById(id) { + const node = this.getById(id); + if (!node) throw new RangeError(`RedXai ID ${id} was not found`); + return node; + } + + getByName(name) { + return (this.nameIndex.get(String(name).toLowerCase()) ?? []).map((record) => record.node); + } + + nextId() { + let max = 1; + for (const id of this.idIndex.keys()) max = Math.max(max, id); + return max + 1; + } + + setById(id, value) { + const node = this.requireById(id); + node.value = valueFrom(value); + this.commitMutation(); + return node; + } + + renameById(id, name) { + if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(name)) throw new TypeError('Invalid RedXai variable name'); + const node = this.requireById(id); + node.name = name; + this.commitMutation(); + return node; + } + + add(databaseIndex, name, value, id = null) { + const database = this.document.databases[databaseIndex]; + if (!database) throw new RangeError(`Database index ${databaseIndex} does not exist`); + const actualId = id ?? this.nextId(); + const node = createAssignment(name, value, actualId); + database.entries.push(node); + this.commitMutation(); + return node; + } + + addToArray(arrayId, name, value, id = null) { + const owner = this.requireById(arrayId); + if (owner.value.valueType !== 'array') throw new TypeError(`ID ${arrayId} is not an array`); + const actualId = id ?? this.nextId(); + const node = createAssignment(name, value, actualId); + owner.value.items.push(node); + this.commitMutation(); + return node; + } + + deleteById(id) { + const record = this.idIndex.get(id); + if (!record) return false; + record.container.splice(record.index, 1); + this.commitMutation(); + return true; + } + + moveById(id, targetArrayId, index = null) { + const record = this.idIndex.get(id); + if (!record) throw new RangeError(`RedXai ID ${id} was not found`); + if (id === targetArrayId) throw new TypeError('A value cannot be moved inside itself'); + const target = this.requireById(targetArrayId); + if (target.value.valueType !== 'array') throw new TypeError(`Target ID ${targetArrayId} is not an array`); + if (containsId(record.node, targetArrayId)) throw new TypeError('A value cannot be moved into one of its descendants'); + + const [node] = record.container.splice(record.index, 1); + const insertion = index == null ? target.value.items.length : Math.max(0, Math.min(index, target.value.items.length)); + target.value.items.splice(insertion, 0, node); + this.commitMutation(); + return node; + } + + copyById(id, options = {}) { + const record = this.idIndex.get(id); + if (!record) throw new RangeError(`RedXai ID ${id} was not found`); + const clone = structuredClone(record.node); + const idMap = new Map(); + let next = this.nextId(); + remapIds(clone, (oldId, isRoot) => { + const replacement = isRoot && options.newId != null ? options.newId : next++; + if (oldId != null) idMap.set(oldId, replacement); + return replacement; + }, true); + if (options.newName) clone.name = options.newName; + + const target = options.targetArrayId == null ? record.container : this.requireArray(options.targetArrayId).value.items; + const insertion = options.index == null ? target.length : Math.max(0, Math.min(options.index, target.length)); + target.splice(insertion, 0, clone); + this.commitMutation(); + return { node: clone, idMap }; + } + + requireArray(id) { + const node = this.requireById(id); + if (node.value.valueType !== 'array') throw new TypeError(`ID ${id} is not an array`); + return node; + } + + transaction(callback) { + const draft = new RedXaiDatabase(this.document, { validate: false }); + const result = callback(draft); + if (result && typeof result.then === 'function') { + return result.then((resolved) => { + assertValid(draft.document); + draft.rebuildIndex(); + this.document = draft.document; + this.rebuildIndex(); + return resolved; + }); + } + assertValid(draft.document); + draft.rebuildIndex(); + this.document = draft.document; + this.rebuildIndex(); + return result; + } + + snapshot() { + return structuredClone(this.document); + } + + commitMutation() { + if (this.options.validate) assertValid(this.document); + this.rebuildIndex(); + } +} + +function containsId(node, id) { + if (node.id === id) return true; + if (node.value?.valueType !== 'array') return false; + return node.value.items.some((item) => item.kind === 'assignment' && containsId(item, id)); +} + +function remapIds(node, allocator, isRoot = false) { + if (node.kind === 'assignment') { + node.id = allocator(node.id, isRoot); + if (node.value.valueType === 'array') { + node.value.items.forEach((item) => { + if (item.kind === 'assignment') remapIds(item, allocator, false); + }); + } + } +} diff --git a/src/editor-server.js b/src/editor-server.js new file mode 100644 index 0000000..8d233ef --- /dev/null +++ b/src/editor-server.js @@ -0,0 +1,116 @@ +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { basename, resolve, join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { RedXaiFileStore } from './store.js'; +import { parse } from './parser.js'; +import { serialize } from './serializer.js'; +import { validate } from './validator.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const editorRoot = resolve(here, '../editor'); +const assetRoot = resolve(here, '../assets'); + +export async function launchEditor(filePath, options = {}) { + const absolutePath = resolve(filePath); + const token = randomBytes(24).toString('hex'); + const store = new RedXaiFileStore(); + + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname.startsWith('/api/')) { + if (url.searchParams.get('token') !== token) return sendJson(response, 403, { error: 'Forbidden' }); + if (url.pathname === '/api/document' && request.method === 'GET') { + const loaded = await store.load(absolutePath); + return sendJson(response, 200, { + fileName: basename(absolutePath), + source: serialize(loaded.document), + warnings: loaded.warnings, + recoveredFrom: loaded.recoveredFrom ?? null, + }); + } + if (url.pathname === '/api/validate' && request.method === 'POST') { + const body = await readJsonBody(request); + const document = parse(String(body.source ?? '')); + return sendJson(response, 200, { issues: validate(document), formatted: serialize(document) }); + } + if (url.pathname === '/api/save' && request.method === 'POST') { + const body = await readJsonBody(request); + const document = parse(String(body.source ?? '')); + const issues = validate(document); + const errors = issues.filter((item) => item.severity === 'error'); + if (errors.length) return sendJson(response, 422, { error: 'Validation failed', issues }); + await store.save(absolutePath, document); + return sendJson(response, 200, { saved: true, source: serialize(document), issues }); + } + return sendJson(response, 404, { error: 'Not found' }); + } + + if (url.pathname === '/') return sendFile(response, join(editorRoot, 'index.html'), 'text/html; charset=utf-8'); + if (url.pathname === '/app.js') return sendFile(response, join(editorRoot, 'app.js'), 'text/javascript; charset=utf-8'); + if (url.pathname === '/styles.css') return sendFile(response, join(editorRoot, 'styles.css'), 'text/css; charset=utf-8'); + if (url.pathname === '/icon.svg') return sendFile(response, join(assetRoot, 'redxai-file-icon.svg'), 'image/svg+xml'); + response.writeHead(404).end('Not found'); + } catch (error) { + sendJson(response, 500, { + error: error.message, + issues: error.issues ?? null, + }); + } + }); + + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(options.port ?? 0, '127.0.0.1', resolveListen); + }); + + const address = server.address(); + const url = `http://127.0.0.1:${address.port}/?token=${token}`; + if (options.open !== false) openBrowser(url); + return { server, url, filePath: absolutePath }; +} + +async function readJsonBody(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > 5 * 1024 * 1024) throw new Error('Editor request exceeds 5 MB'); + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); +} + +async function sendFile(response, path, contentType) { + const content = await readFile(path); + response.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': content.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", + }); + response.end(content); +} + +function sendJson(response, status, payload) { + const body = Buffer.from(JSON.stringify(payload)); + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': body.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(body); +} + +function openBrowser(url) { + const platform = process.platform; + const command = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open'; + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..e97fa1e --- /dev/null +++ b/src/errors.js @@ -0,0 +1,23 @@ +export class RedXaiSyntaxError extends SyntaxError { + constructor(message, location) { + const suffix = location ? ` at ${location.line}:${location.column}` : ''; + super(`${message}${suffix}`); + this.name = 'RedXaiSyntaxError'; + this.location = location ?? null; + } +} + +export class RedXaiValidationError extends Error { + constructor(issues) { + super(`RedXai validation failed with ${issues.length} issue${issues.length === 1 ? '' : 's'}`); + this.name = 'RedXaiValidationError'; + this.issues = issues; + } +} + +export class RedXaiStorageError extends Error { + constructor(message, options = {}) { + super(message, options); + this.name = 'RedXaiStorageError'; + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a2ec0aa --- /dev/null +++ b/src/index.js @@ -0,0 +1,21 @@ +export { Lexer, tokenize } from './lexer.js'; +export { Parser, parse, parseValue } from './parser.js'; +export { serialize, serializeValue } from './serializer.js'; +export { validate, assertValid, validateProject, isAccessPoint } from './validator.js'; +export { RedXaiDatabase } from './database.js'; +export { RedXaiFileStore, FILE_EXTENSION, assertExtension } from './store.js'; +export { + valueFrom, + nellValue, + booleanValue, + stringValue, + numberValue, + percentValue, + referenceById, + referenceByName, + assignment, + createDatabase, + createDocument, + toPlainValue, +} from './model.js'; +export { RedXaiSyntaxError, RedXaiValidationError, RedXaiStorageError } from './errors.js'; diff --git a/src/lexer.js b/src/lexer.js new file mode 100644 index 0000000..d751037 --- /dev/null +++ b/src/lexer.js @@ -0,0 +1,187 @@ +import { RedXaiSyntaxError } from './errors.js'; + +const SINGLE_CHAR = new Map([ + ['{', 'LBRACE'], ['}', 'RBRACE'], ['[', 'LBRACKET'], [']', 'RBRACKET'], + ['=', 'EQUALS'], [',', 'COMMA'], [';', 'SEMICOLON'], [':', 'COLON'], + ['%', 'PERCENT'], ['@', 'AT'], ['$','DOLLAR'], +]); + +const isDigit = (ch) => ch >= '0' && ch <= '9'; +const isIdentifierStart = (ch) => /[A-Za-z_]/.test(ch ?? ''); +const isIdentifierPart = (ch) => /[A-Za-z0-9_\-.]/.test(ch ?? ''); + +export class Lexer { + constructor(source) { + this.source = source.replace(/^\uFEFF/, ''); + this.index = 0; + this.line = 1; + this.column = 1; + } + + tokenize() { + const tokens = []; + while (!this.isAtEnd()) { + const ch = this.peek(); + if (/\s/.test(ch)) { + this.consumeWhitespace(); + continue; + } + if (ch === '<' && this.peek(1) === '<') { + tokens.push(this.readComment()); + continue; + } + if (ch === '"') { + tokens.push(this.readString()); + continue; + } + if (isDigit(ch) || (ch === '-' && isDigit(this.peek(1)))) { + tokens.push(this.readNumber()); + continue; + } + if (isIdentifierStart(ch)) { + tokens.push(this.readIdentifier()); + continue; + } + const type = SINGLE_CHAR.get(ch); + if (type) { + tokens.push(this.makeToken(type, ch, ch.length)); + continue; + } + throw new RedXaiSyntaxError(`Unexpected character ${JSON.stringify(ch)}`, this.location()); + } + tokens.push({ type: 'EOF', value: '', start: this.location(), end: this.location() }); + return tokens; + } + + readComment() { + const start = this.location(); + this.advance(); + this.advance(); + + const contentStart = this.index; + const nextClose = this.source.indexOf('>>', this.index); + const nextOpen = this.source.indexOf('<<', this.index); + const closesBeforeAnotherOpen = nextClose !== -1 && (nextOpen === -1 || nextClose < nextOpen); + + if (closesBeforeAnotherOpen) { + while (this.index < nextClose) this.advance(); + const value = this.source.slice(contentStart, nextClose).trim(); + this.advance(); + this.advance(); + return { type: 'COMMENT', value, style: 'block', start, end: this.location() }; + } + + while (!this.isAtEnd() && this.peek() !== '\n') this.advance(); + const value = this.source.slice(contentStart, this.index).trim(); + return { type: 'COMMENT', value, style: 'line', start, end: this.location() }; + } + + readString() { + const start = this.location(); + this.advance(); + let value = ''; + while (!this.isAtEnd()) { + const ch = this.advance(); + if (ch === '"') { + return { type: 'STRING', value, start, end: this.location() }; + } + if (ch === '\\') { + if (this.isAtEnd()) break; + const escaped = this.advance(); + const map = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\', b: '\b', f: '\f' }; + if (escaped === 'u') { + const hex = this.source.slice(this.index, this.index + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) { + throw new RedXaiSyntaxError('Invalid Unicode escape', this.location()); + } + value += String.fromCharCode(Number.parseInt(hex, 16)); + for (let i = 0; i < 4; i += 1) this.advance(); + } else if (Object.hasOwn(map, escaped)) { + value += map[escaped]; + } else { + throw new RedXaiSyntaxError(`Unsupported escape \\${escaped}`, this.location()); + } + } else { + value += ch; + } + } + throw new RedXaiSyntaxError('Unterminated string', start); + } + + readNumber() { + const start = this.location(); + const begin = this.index; + if (this.peek() === '-') this.advance(); + while (isDigit(this.peek())) this.advance(); + if (this.peek() === '.' && isDigit(this.peek(1))) { + this.advance(); + while (isDigit(this.peek())) this.advance(); + } + if ((this.peek() === 'e' || this.peek() === 'E')) { + const mark = this.index; + this.advance(); + if (this.peek() === '+' || this.peek() === '-') this.advance(); + if (!isDigit(this.peek())) { + this.index = mark; + this.recomputeLocation(); + } else { + while (isDigit(this.peek())) this.advance(); + } + } + const raw = this.source.slice(begin, this.index); + return { type: 'NUMBER', value: Number(raw), raw, start, end: this.location() }; + } + + readIdentifier() { + const start = this.location(); + const begin = this.index; + this.advance(); + while (isIdentifierPart(this.peek())) this.advance(); + const value = this.source.slice(begin, this.index); + return { type: 'IDENTIFIER', value, start, end: this.location() }; + } + + consumeWhitespace() { + while (!this.isAtEnd() && /\s/.test(this.peek())) this.advance(); + } + + makeToken(type, value, length) { + const start = this.location(); + for (let i = 0; i < length; i += 1) this.advance(); + return { type, value, start, end: this.location() }; + } + + peek(offset = 0) { + return this.source[this.index + offset]; + } + + advance() { + const ch = this.source[this.index++]; + if (ch === '\n') { + this.line += 1; + this.column = 1; + } else { + this.column += 1; + } + return ch; + } + + isAtEnd() { + return this.index >= this.source.length; + } + + location() { + return { offset: this.index, line: this.line, column: this.column }; + } + + recomputeLocation() { + const prefix = this.source.slice(0, this.index); + const lines = prefix.split('\n'); + this.line = lines.length; + this.column = lines.at(-1).length + 1; + } +} + +export function tokenize(source) { + return new Lexer(source).tokenize(); +} diff --git a/src/model.js b/src/model.js new file mode 100644 index 0000000..3f2037b --- /dev/null +++ b/src/model.js @@ -0,0 +1,113 @@ +export function valueFrom(input) { + if (input && input.kind === 'value') return structuredClone(input); + if (input === null || input === undefined) return nellValue(); + if (typeof input === 'string') return { kind: 'value', valueType: 'string', value: input }; + if (typeof input === 'number') { + if (!Number.isFinite(input)) throw new TypeError('RedXai numbers must be finite'); + return { kind: 'value', valueType: 'number', value: input }; + } + if (typeof input === 'boolean') return { kind: 'value', valueType: 'boolean', value: input }; + if (Array.isArray(input)) { + return { kind: 'value', valueType: 'array', items: input.map((item) => valueFrom(item)) }; + } + if (typeof input === 'object') { + return { + kind: 'value', + valueType: 'array', + items: Object.entries(input).map(([name, value]) => ({ + kind: 'assignment', + name, + id: null, + value: valueFrom(value), + })), + }; + } + throw new TypeError(`Unsupported RedXai value: ${typeof input}`); +} + +export function nellValue() { + return { kind: 'value', valueType: 'nell', value: null }; +} + +export function booleanValue(value) { + return { kind: 'value', valueType: 'boolean', value: Boolean(value) }; +} + +export function stringValue(value) { + return { kind: 'value', valueType: 'string', value: String(value) }; +} + +export function numberValue(value) { + if (!Number.isFinite(value)) throw new TypeError('RedXai numbers must be finite'); + return { kind: 'value', valueType: 'number', value }; +} + +export function percentValue(value) { + if (!Number.isFinite(value)) throw new TypeError('RedXai percentages must be finite'); + return { kind: 'value', valueType: 'percent', value }; +} + +export function referenceById(id) { + return { kind: 'value', valueType: 'reference', target: { type: 'id', value: id } }; +} + +export function referenceByName(name) { + return { kind: 'value', valueType: 'reference', target: { type: 'name', value: name } }; +} + +export function assignment(name, value, id = null) { + return { kind: 'assignment', name, id, value: valueFrom(value) }; +} + +export function createDatabase(options = {}) { + return { + kind: 'database', + name: 'RedXaiStore', + id: 1, + version: options.version ?? '0.1', + headerExtras: {}, + entries: options.entries ? structuredClone(options.entries) : [], + metadata: { + shared: booleanValue(options.shared ?? false), + shareId: options.shareId == null ? nellValue() : valueFrom(options.shareId), + allowedProjectFiles: options.allowedProjectFiles == null + ? nellValue() + : { kind: 'value', valueType: 'collection', items: options.allowedProjectFiles.map(stringValue) }, + globalAccess: booleanValue(options.globalAccess ?? false), + globalId: options.globalId == null ? nellValue() : valueFrom(options.globalId), + extras: {}, + syntax: 'canonical', + }, + }; +} + +export function createDocument(options = {}) { + return { + kind: 'document', + format: 'RedXaiHM', + databases: options.databases ? structuredClone(options.databases) : [createDatabase(options)], + }; +} + +export function toPlainValue(value, options = {}) { + switch (value.valueType) { + case 'nell': return null; + case 'string': + case 'number': + case 'percent': + case 'boolean': + case 'symbol': return value.value; + case 'reference': return options.referencesAsObjects === false ? `@${value.target.value}` : { $ref: value.target }; + case 'collection': return value.items.filter((item) => item.kind === 'value').map((item) => toPlainValue(item, options)); + case 'array': { + const content = value.items.filter((item) => item.kind !== 'comment'); + if (content.every((item) => item.kind === 'assignment')) { + return Object.fromEntries(content.map((item) => [item.name, toPlainValue(item.value, options)])); + } + return content.map((item) => item.kind === 'assignment' + ? { [item.name]: toPlainValue(item.value, options) } + : toPlainValue(item, options)); + } + default: throw new TypeError(`Unknown RedXai value type ${value.valueType}`); + } +} diff --git a/src/parser.js b/src/parser.js new file mode 100644 index 0000000..e3fe683 --- /dev/null +++ b/src/parser.js @@ -0,0 +1,342 @@ +import { tokenize } from './lexer.js'; +import { RedXaiSyntaxError } from './errors.js'; + +const canonicalKey = (value) => value.toLowerCase(); + +export class Parser { + constructor(source) { + this.source = source; + this.tokens = tokenize(source); + this.current = 0; + } + + parseDocument() { + const databases = []; + while (!this.check('EOF')) { + databases.push(this.parseDatabase()); + } + if (databases.length === 0) this.error(this.peek(), 'A RedXai document must contain at least one database'); + return { kind: 'document', format: 'RedXaiHM', databases }; + } + + parseDatabase() { + const start = this.consume('LBRACE', "Expected '{' to begin a database").start; + const name = this.consume('IDENTIFIER', 'Expected RedXaiStore database header'); + if (name.value !== 'RedXaiStore') this.error(name, 'Database header must be exactly RedXaiStore'); + + this.consume('LBRACKET', "Expected '[' after RedXaiStore"); + const header = this.parseHeaderProperties(); + this.consume('RBRACKET', "Expected ']' after database header"); + this.consume('EQUALS', "Expected '=' after database header"); + this.consume('LBRACKET', "Expected '[' before database contents"); + + const entries = this.parseEntrySequence('RBRACKET'); + this.consume('RBRACKET', "Expected ']' after database contents"); + + const metadata = this.parseDatabaseMetadata(); + this.consume('RBRACE', "Expected '}' after database metadata"); + const end = this.consume('SEMICOLON', "Expected ';' after database").end; + + return { + kind: 'database', + name: 'RedXaiStore', + id: header.id, + version: header.version, + headerExtras: header.extras, + entries, + metadata, + location: { start, end }, + }; + } + + parseHeaderProperties() { + let id; + let version = '0.1'; + const extras = {}; + + while (!this.check('RBRACKET') && !this.check('EOF')) { + const key = this.consume('IDENTIFIER', 'Expected database header property'); + this.consume('EQUALS', `Expected '=' after ${key.value}`); + const value = this.parseScalarValue(); + const normalized = canonicalKey(key.value); + if (normalized === 'id') id = scalarToPrimitive(value); + else if (normalized === 'version') version = String(scalarToPrimitive(value)); + else extras[key.value] = value; + if (!this.match('COMMA')) break; + } + + if (id === undefined) this.error(this.peek(), 'Database header requires ID=1'); + return { id, version, extras }; + } + + parseEntrySequence(terminator) { + const entries = []; + while (!this.check(terminator) && !this.check('EOF')) { + if (this.match('COMMA')) continue; + if (this.check('COMMENT')) entries.push(this.parseComment()); + else entries.push(this.parseAssignment()); + this.match('COMMA'); + } + return entries; + } + + parseAssignment() { + const start = this.consume('LBRACE', "Expected '{' to begin a variable").start; + const name = this.consume('IDENTIFIER', 'Expected variable name'); + this.consume('LBRACKET', "Expected '[' after variable name"); + const id = this.parseOptionalId(); + this.consume('RBRACKET', "Expected ']' after variable ID"); + this.consume('RBRACE', "Expected '}' after variable declaration"); + this.consume('EQUALS', "Expected '=' before variable value"); + const value = this.parseValue(); + return { + kind: 'assignment', + name: name.value, + id, + value, + location: { start, end: value.location?.end ?? this.previous().end }, + }; + } + + parseOptionalId() { + if (this.check('RBRACKET')) return null; + if (this.check('NUMBER')) return this.advance().value; + const key = this.consume('IDENTIFIER', 'Expected a numeric ID or ID='); + if (canonicalKey(key.value) !== 'id') this.error(key, 'Named variable ID must use ID='); + this.consume('EQUALS', "Expected '=' after ID"); + return this.consume('NUMBER', 'Expected numeric ID').value; + } + + parseValue() { + if (this.check('STRING')) { + const token = this.advance(); + return valueNode('string', token.value, token); + } + if (this.check('NUMBER')) { + const token = this.advance(); + if (this.match('PERCENT')) { + return { kind: 'value', valueType: 'percent', value: token.value, location: { start: token.start, end: this.previous().end } }; + } + return valueNode('number', token.value, token); + } + if (this.check('IDENTIFIER')) return this.parseIdentifierValue(); + if (this.match('SEMICOLON')) return this.parseArray(this.previous().start); + if (this.check('LBRACE')) return this.parseCollection(); + if (this.match('AT')) return this.parseReference(this.previous().start); + this.error(this.peek(), 'Expected a RedXai value'); + } + + parseScalarValue() { + const value = this.parseValue(); + if (value.valueType === 'array' || value.valueType === 'collection') { + this.error(this.previous(), 'Expected a scalar value'); + } + return value; + } + + parseIdentifierValue() { + const token = this.advance(); + const normalized = canonicalKey(token.value); + if (normalized === 'true' || normalized === 'false') { + return valueNode('boolean', normalized === 'true', token); + } + if (normalized === 'nell') { + const end = this.match('DOLLAR') ? this.previous().end : token.end; + return { kind: 'value', valueType: 'nell', value: null, location: { start: token.start, end } }; + } + return valueNode('symbol', token.value, token); + } + + parseArray(start) { + this.consume('LBRACKET', "Expected '[' after ';' to begin an array"); + const items = []; + while (!this.check('RBRACKET') && !this.check('EOF')) { + if (this.match('COMMA')) continue; + if (this.check('COMMENT')) items.push(this.parseComment()); + else if (this.looksLikeAssignment()) items.push(this.parseAssignment()); + else items.push(this.parseValue()); + this.match('COMMA'); + } + this.consume('RBRACKET', "Expected ']' after array"); + const end = this.consume('COLON', "Expected ':' after array").end; + return { kind: 'value', valueType: 'array', items, location: { start, end } }; + } + + parseCollection() { + const start = this.consume('LBRACE', "Expected '{' to begin a collection").start; + const items = []; + while (!this.check('RBRACE') && !this.check('EOF')) { + if (this.match('COMMA')) continue; + if (this.check('COMMENT')) items.push(this.parseComment()); + else items.push(this.parseValue()); + this.match('COMMA'); + } + const end = this.consume('RBRACE', "Expected '}' after collection").end; + return { kind: 'value', valueType: 'collection', items, location: { start, end } }; + } + + parseReference(start) { + if (this.check('NUMBER')) { + const token = this.advance(); + return { kind: 'value', valueType: 'reference', target: { type: 'id', value: token.value }, location: { start, end: token.end } }; + } + const token = this.consume('IDENTIFIER', "Expected an ID or name after '@'"); + return { kind: 'value', valueType: 'reference', target: { type: 'name', value: token.value }, location: { start, end: token.end } }; + } + + parseComment() { + const token = this.consume('COMMENT', 'Expected comment'); + return { kind: 'comment', style: token.style, value: token.value, location: { start: token.start, end: token.end } }; + } + + parseDatabaseMetadata() { + if (this.checkIdentifier('metadata')) { + this.advance(); + this.consume('EQUALS', "Expected '=' after Metadata"); + return this.parseNamedMetadata(); + } + if (this.check('RBRACE')) return defaultMetadata(); + return this.parseLegacyMetadata(); + } + + parseNamedMetadata() { + this.consume('LBRACE', "Expected '{' after Metadata="); + const raw = {}; + while (!this.check('RBRACE') && !this.check('EOF')) { + if (this.match('COMMA')) continue; + const key = this.consume('IDENTIFIER', 'Expected metadata property'); + this.consume('EQUALS', `Expected '=' after ${key.value}`); + raw[key.value] = this.parseValue(); + this.match('COMMA'); + } + this.consume('RBRACE', "Expected '}' after metadata properties"); + return normalizeMetadata(raw); + } + + parseLegacyMetadata() { + const shared = this.parseValue(); + this.consume('COMMA', "Expected ',' after shared flag"); + const shareId = this.parseValue(); + this.consume('COMMA', "Expected ',' after share ID"); + + if (this.checkIdentifier('opfnames') && this.peek(1).type === 'EQUALS') { + this.advance(); + this.advance(); + } + const projectFiles = this.parseValue(); + this.consume('COMMA', "Expected ',' after OPFNames"); + + if (this.checkIdentifier('ga') && this.peek(1).type === 'EQUALS') { + this.advance(); + this.advance(); + } + const globalAccess = this.parseValue(); + this.consume('COMMA', "Expected ',' after global access flag"); + + if (this.checkIdentifier('id') && this.peek(1).type === 'EQUALS') { + this.advance(); + this.advance(); + } + const globalId = this.parseValue(); + + return { + shared, + shareId, + allowedProjectFiles: projectFiles, + globalAccess, + globalId, + extras: {}, + syntax: 'legacy', + }; + } + + looksLikeAssignment() { + return this.check('LBRACE') && this.peek(1).type === 'IDENTIFIER' && this.peek(2).type === 'LBRACKET'; + } + + checkIdentifier(value) { + return this.check('IDENTIFIER') && canonicalKey(this.peek().value) === canonicalKey(value); + } + + match(...types) { + for (const type of types) { + if (this.check(type)) { + this.advance(); + return true; + } + } + return false; + } + + consume(type, message) { + if (this.check(type)) return this.advance(); + this.error(this.peek(), message); + } + + check(type) { + return this.peek().type === type; + } + + advance() { + if (!this.check('EOF')) this.current += 1; + return this.previous(); + } + + peek(offset = 0) { + return this.tokens[Math.min(this.current + offset, this.tokens.length - 1)]; + } + + previous() { + return this.tokens[this.current - 1]; + } + + error(token, message) { + throw new RedXaiSyntaxError(message, token.start); + } +} + +function valueNode(valueType, value, token) { + return { kind: 'value', valueType, value, location: { start: token.start, end: token.end } }; +} + +function scalarToPrimitive(node) { + if (node.valueType === 'nell') return null; + return node.value; +} + +function defaultMetadata() { + return { + shared: { kind: 'value', valueType: 'boolean', value: false }, + shareId: { kind: 'value', valueType: 'nell', value: null }, + allowedProjectFiles: { kind: 'value', valueType: 'nell', value: null }, + globalAccess: { kind: 'value', valueType: 'boolean', value: false }, + globalId: { kind: 'value', valueType: 'nell', value: null }, + extras: {}, + syntax: 'canonical', + }; +} + +function normalizeMetadata(raw) { + const result = defaultMetadata(); + const extras = {}; + for (const [key, value] of Object.entries(raw)) { + const normalized = canonicalKey(key); + if (normalized === 'shared') result.shared = value; + else if (normalized === 'shareid' || normalized === 'projectid') result.shareId = value; + else if (normalized === 'opfnames' || normalized === 'allowedprojectfiles') result.allowedProjectFiles = value; + else if (normalized === 'ga' || normalized === 'globalaccess') result.globalAccess = value; + else if (normalized === 'globalid' || normalized === 'id') result.globalId = value; + else extras[key] = value; + } + result.extras = extras; + return result; +} + +export function parse(source) { + return new Parser(source).parseDocument(); +} + +export function parseValue(source) { + const parser = new Parser(`{RedXaiStore[ID=1]=[{Value[]}= ${source}]};`); + return parser.parseDocument().databases[0].entries[0].value; +} diff --git a/src/serializer.js b/src/serializer.js new file mode 100644 index 0000000..cc49f10 --- /dev/null +++ b/src/serializer.js @@ -0,0 +1,105 @@ +const indentText = (level, size) => ' '.repeat(level * size); + +export function serialize(document, options = {}) { + const indentSize = options.indent ?? 2; + const newline = options.newline ?? '\n'; + return document.databases + .map((database) => serializeDatabase(database, indentSize, newline)) + .join(`${newline}${newline}`) + newline; +} + +function serializeDatabase(database, indentSize, newline) { + const headerParts = [`ID=${database.id}`, `Version=${quote(database.version)}`]; + for (const [key, value] of Object.entries(database.headerExtras ?? {})) { + headerParts.push(`${key}=${serializeValue(value, 0, indentSize, newline)}`); + } + + const lines = [`{RedXaiStore[${headerParts.join(', ')}]=[`]; + for (let i = 0; i < database.entries.length; i += 1) { + const entry = database.entries[i]; + const rendered = serializeEntry(entry, 1, indentSize, newline); + const comma = entry.kind === 'assignment' && hasFollowingContent(database.entries, i) ? ',' : ''; + lines.push(rendered + comma); + } + lines.push(`] Metadata={`); + lines.push(`${indentText(1, indentSize)}Shared=${serializeValue(database.metadata.shared, 1, indentSize, newline)},`); + lines.push(`${indentText(1, indentSize)}ShareID=${serializeValue(database.metadata.shareId, 1, indentSize, newline)},`); + lines.push(`${indentText(1, indentSize)}OPFNames=${serializeValue(database.metadata.allowedProjectFiles, 1, indentSize, newline)},`); + lines.push(`${indentText(1, indentSize)}GA=${serializeValue(database.metadata.globalAccess, 1, indentSize, newline)},`); + lines.push(`${indentText(1, indentSize)}GlobalID=${serializeValue(database.metadata.globalId, 1, indentSize, newline)}${Object.keys(database.metadata.extras ?? {}).length ? ',' : ''}`); + const extras = Object.entries(database.metadata.extras ?? {}); + extras.forEach(([key, value], index) => { + lines.push(`${indentText(1, indentSize)}${key}=${serializeValue(value, 1, indentSize, newline)}${index < extras.length - 1 ? ',' : ''}`); + }); + lines.push(`}};`); + return lines.join(newline); +} + +function serializeEntry(entry, level, indentSize, newline) { + if (entry.kind === 'comment') return serializeComment(entry, level, indentSize, newline); + const id = entry.id == null ? '' : `ID=${entry.id}`; + const prefix = `${indentText(level, indentSize)}{${entry.name}[${id}]} = `; + return prefix + serializeValue(entry.value, level, indentSize, newline); +} + +export function serializeValue(value, level = 0, indentSize = 2, newline = '\n') { + switch (value.valueType) { + case 'string': return quote(value.value); + case 'number': return String(value.value); + case 'percent': return `${value.value}%`; + case 'boolean': return value.value ? 'TRUE' : 'FALSE'; + case 'nell': return 'NELL'; + case 'symbol': return value.value; + case 'reference': return `@${value.target.value}`; + case 'collection': return serializeCollection(value, level, indentSize, newline); + case 'array': return serializeArray(value, level, indentSize, newline); + default: throw new TypeError(`Cannot serialize unknown RedXai value type: ${value.valueType}`); + } +} + +function serializeCollection(value, level, indentSize, newline) { + const content = value.items.filter((item) => item.kind !== 'comment'); + if (content.length === 0) return '{}'; + if (content.every((item) => item.kind === 'value' && !['array', 'collection'].includes(item.valueType))) { + return `{${content.map((item) => serializeValue(item, level, indentSize, newline)).join(', ')}}`; + } + const lines = ['{']; + value.items.forEach((item, index) => { + const rendered = item.kind === 'comment' + ? serializeComment(item, level + 1, indentSize, newline) + : `${indentText(level + 1, indentSize)}${serializeValue(item, level + 1, indentSize, newline)}`; + lines.push(rendered + (item.kind !== 'comment' && hasFollowingContent(value.items, index) ? ',' : '')); + }); + lines.push(`${indentText(level, indentSize)}}`); + return lines.join(newline); +} + +function serializeArray(value, level, indentSize, newline) { + if (value.items.length === 0) return ';[]:'; + const lines = [';[']; + value.items.forEach((item, index) => { + let rendered; + if (item.kind === 'assignment' || item.kind === 'comment') rendered = serializeEntry(item, level + 1, indentSize, newline); + else rendered = `${indentText(level + 1, indentSize)}${serializeValue(item, level + 1, indentSize, newline)}`; + lines.push(rendered + (item.kind !== 'comment' && hasFollowingContent(value.items, index) ? ',' : '')); + }); + lines.push(`${indentText(level, indentSize)}]:`); + return lines.join(newline); +} + +function serializeComment(comment, level, indentSize, newline) { + const indent = indentText(level, indentSize); + if (comment.style === 'block' || comment.value.includes('\n')) { + const body = comment.value.split('\n').map((line) => `${indent}${line}`).join(newline); + return `${indent}<<${newline}${body}${newline}${indent}>>`; + } + return `${indent}<< ${comment.value}`; +} + +function quote(value) { + return JSON.stringify(String(value)); +} + +function hasFollowingContent(items, index) { + return items.slice(index + 1).some((item) => item.kind !== 'comment'); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..8802f5b --- /dev/null +++ b/src/store.js @@ -0,0 +1,143 @@ +import { open, readFile, rename, copyFile, mkdir, stat, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { parse } from './parser.js'; +import { serialize } from './serializer.js'; +import { assertValid } from './validator.js'; +import { RedXaiDatabase } from './database.js'; +import { RedXaiStorageError } from './errors.js'; + +const FILE_EXTENSION = '.RedXai'; + +export class RedXaiFileStore { + constructor(options = {}) { + this.options = { + lockTimeoutMs: options.lockTimeoutMs ?? 5_000, + staleLockMs: options.staleLockMs ?? 30_000, + backup: options.backup !== false, + recover: options.recover !== false, + indent: options.indent ?? 2, + }; + } + + async load(filePath) { + assertExtension(filePath); + try { + return await this.loadExact(filePath); + } catch (primaryError) { + if (!this.options.recover) throw primaryError; + const backupPath = `${filePath}.bak`; + try { + const recovered = await this.loadExact(backupPath, false); + recovered.recoveredFrom = backupPath; + return recovered; + } catch { + throw new RedXaiStorageError(`Unable to load ${filePath} or its backup`, { cause: primaryError }); + } + } + } + + async loadExact(filePath, checkExtension = true) { + if (checkExtension) assertExtension(filePath); + const source = await readFile(filePath, 'utf8'); + const document = parse(source); + const warnings = assertValid(document); + return { filePath, source, document, database: new RedXaiDatabase(document), warnings }; + } + + async save(filePath, document) { + assertExtension(filePath); + const warnings = assertValid(document); + const source = serialize(document, { indent: this.options.indent }); + await mkdir(dirname(filePath), { recursive: true }); + const release = await this.acquireLock(filePath); + const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + try { + if (this.options.backup && await exists(filePath)) await copyFile(filePath, `${filePath}.bak`); + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(source, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporaryPath, filePath); + await syncDirectory(dirname(filePath)); + return { filePath, bytes: Buffer.byteLength(source), warnings }; + } catch (error) { + await unlink(temporaryPath).catch(() => {}); + throw new RedXaiStorageError(`Failed to save ${filePath}`, { cause: error }); + } finally { + await release(); + } + } + + async transaction(filePath, callback) { + const loaded = await this.load(filePath); + const result = await loaded.database.transaction(callback); + await this.save(filePath, loaded.database.document); + return result; + } + + async acquireLock(filePath) { + const lockPath = `${filePath}.lock`; + const started = Date.now(); + while (true) { + try { + const handle = await open(lockPath, 'wx', 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); + return async () => { + await handle.close().catch(() => {}); + await unlink(lockPath).catch(() => {}); + }; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + if (await isStale(lockPath, this.options.staleLockMs)) { + await unlink(lockPath).catch(() => {}); + continue; + } + if (Date.now() - started >= this.options.lockTimeoutMs) { + throw new RedXaiStorageError(`Timed out waiting for database lock ${lockPath}`); + } + await delay(50); + } + } + } +} + +export function assertExtension(filePath) { + if (!String(filePath).endsWith(FILE_EXTENSION)) { + throw new RedXaiStorageError(`RedXaiHM database files must use the exact ${FILE_EXTENSION} extension`); + } +} + +async function exists(filePath) { + try { + await stat(filePath); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +async function isStale(lockPath, staleMs) { + try { + const info = await stat(lockPath); + return Date.now() - info.mtimeMs > staleMs; + } catch (error) { + return error?.code === 'ENOENT'; + } +} + +async function syncDirectory(directory) { + try { + const handle = await open(directory, 'r'); + try { await handle.sync(); } finally { await handle.close(); } + } catch { + // Some platforms do not support fsync on directories. The file itself is already synced. + } +} + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export { FILE_EXTENSION }; diff --git a/src/validator.js b/src/validator.js new file mode 100644 index 0000000..6219fec --- /dev/null +++ b/src/validator.js @@ -0,0 +1,282 @@ +import { RedXaiValidationError } from './errors.js'; + +const issue = (code, message, path, node, severity = 'error') => ({ + code, + message, + path, + severity, + location: node?.location?.start ?? null, +}); + +export function validate(document) { + const issues = []; + if (!document || document.kind !== 'document' || !Array.isArray(document.databases)) { + return [issue('RXH000', 'Root value must be a RedXai document', '$', document)]; + } + if (document.databases.length === 0) { + issues.push(issue('RXH001', 'Document must contain at least one database', '$', document)); + return issues; + } + + const projectIds = new Map(); + const referenceIds = collectDocumentIds(document); + document.databases.forEach((database, databaseIndex) => { + const path = `$.databases[${databaseIndex}]`; + validateDatabase(database, path, issues, projectIds, referenceIds); + }); + return issues; +} + +function validateDatabase(database, path, issues, projectIds, referenceIds) { + if (database.name !== 'RedXaiStore') { + issues.push(issue('RXH100', 'Database header must be named RedXaiStore', `${path}.name`, database)); + } + if (database.id !== 1 || !Number.isInteger(database.id)) { + issues.push(issue('RXH101', 'Every database header ID must be exactly 1', `${path}.id`, database)); + } + if (typeof database.version !== 'string' || database.version.trim() === '') { + issues.push(issue('RXH102', 'Database Version must be a non-empty string', `${path}.version`, database)); + } + + const localIds = new Map(); + const localNames = new Map(); + let encounteredRegularEntry = false; + + for (let i = 0; i < database.entries.length; i += 1) { + const entry = database.entries[i]; + const entryPath = `${path}.entries[${i}]`; + if (entry.kind === 'comment') continue; + const accessPoint = isAccessPoint(entry); + if (accessPoint && encounteredRegularEntry) { + issues.push(issue('RXH130', 'Access points must appear before stored values', entryPath, entry)); + } + if (!accessPoint) encounteredRegularEntry = true; + validateAssignment(entry, entryPath, issues, localIds, localNames, projectIds); + } + + validateMetadata(database.metadata, `${path}.metadata`, issues, database); + validateReferences(database, path, issues, localIds, localNames, referenceIds); +} + +function validateAssignment(node, path, issues, localIds, localNames, projectIds) { + if (!node || node.kind !== 'assignment') { + issues.push(issue('RXH110', 'Database entries must be variables or comments', path, node)); + return; + } + if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(node.name)) { + issues.push(issue('RXH111', `Invalid variable name ${JSON.stringify(node.name)}`, `${path}.name`, node)); + } + if (node.id !== null && node.id !== undefined) { + if (!Number.isInteger(node.id) || node.id <= 1) { + issues.push(issue('RXH112', 'Variable IDs must be integers greater than 1; 0 and 1 are reserved', `${path}.id`, node)); + } else { + registerId(localIds, node.id, path, node, issues, 'database'); + registerId(projectIds, node.id, path, node, issues, 'document/project'); + } + } + + const normalizedName = node.name.toLowerCase(); + const names = localNames.get(normalizedName) ?? []; + names.push({ node, path }); + localNames.set(normalizedName, names); + validateValue(node.value, `${path}.value`, issues, localIds, localNames, projectIds); + + if (isAccessPoint(node)) validateAccessPoint(node, path, issues); +} + +function validateValue(value, path, issues, localIds, localNames, projectIds) { + if (!value || value.kind !== 'value') { + issues.push(issue('RXH120', 'Expected a RedXai value node', path, value)); + return; + } + switch (value.valueType) { + case 'string': + if (typeof value.value !== 'string') issues.push(issue('RXH121', 'String value must contain text', path, value)); + break; + case 'number': + case 'percent': + if (typeof value.value !== 'number' || !Number.isFinite(value.value)) { + issues.push(issue('RXH122', `${value.valueType} must be a finite number`, path, value)); + } + break; + case 'boolean': + if (typeof value.value !== 'boolean') issues.push(issue('RXH123', 'Boolean value must be true or false', path, value)); + break; + case 'nell': + break; + case 'symbol': + if (typeof value.value !== 'string' || value.value.length === 0) issues.push(issue('RXH124', 'Symbol cannot be empty', path, value)); + break; + case 'reference': + if (!value.target || !['id', 'name'].includes(value.target.type)) { + issues.push(issue('RXH125', 'Reference must target an ID or name', path, value)); + } + break; + case 'array': + value.items.forEach((item, index) => { + const itemPath = `${path}.items[${index}]`; + if (item.kind === 'assignment') validateAssignment(item, itemPath, issues, localIds, localNames, projectIds); + else if (item.kind === 'value') validateValue(item, itemPath, issues, localIds, localNames, projectIds); + else if (item.kind !== 'comment') issues.push(issue('RXH126', 'Invalid array item', itemPath, item)); + }); + break; + case 'collection': + value.items.forEach((item, index) => { + const itemPath = `${path}.items[${index}]`; + if (item.kind === 'value') validateValue(item, itemPath, issues, localIds, localNames, projectIds); + else if (item.kind !== 'comment') issues.push(issue('RXH127', 'Collections can contain values and comments only', itemPath, item)); + }); + break; + default: + issues.push(issue('RXH128', `Unknown value type ${JSON.stringify(value.valueType)}`, path, value)); + } +} + +function validateMetadata(metadata, path, issues, database) { + const shared = primitive(metadata.shared); + const globalAccess = primitive(metadata.globalAccess); + if (typeof shared !== 'boolean') issues.push(issue('RXH140', 'Shared must be a Boolean', `${path}.shared`, metadata.shared)); + if (typeof globalAccess !== 'boolean') issues.push(issue('RXH141', 'GlobalAccess/GA must be a Boolean', `${path}.globalAccess`, metadata.globalAccess)); + + if (shared === true) { + const shareId = primitive(metadata.shareId); + if (!Number.isInteger(shareId) || shareId <= 1) issues.push(issue('RXH142', 'A shared database requires a numeric ShareID/ProjectID greater than 1', `${path}.shareId`, metadata.shareId)); + const files = collectionStrings(metadata.allowedProjectFiles); + if (!files || files.length === 0) { + issues.push(issue('RXH143', 'A shared database requires at least one OPFNames/AllowedProjectFiles entry', `${path}.allowedProjectFiles`, metadata.allowedProjectFiles)); + } + } + + if (globalAccess === true) { + const id = primitive(metadata.globalId); + if (!Number.isInteger(id) || id <= 1) { + issues.push(issue('RXH144', 'A global database requires a numeric GlobalID greater than 1', `${path}.globalId`, metadata.globalId)); + } + } + + if (database.entries.every((entry) => entry.kind === 'comment')) { + issues.push(issue('RXH145', 'Database contains no stored values', path, database, 'warning')); + } +} + +function validateReferences(database, path, issues, localIds, localNames, referenceIds) { + walkEntries(database.entries, (value, valuePath) => { + if (value.kind !== 'value' || value.valueType !== 'reference') return; + if (value.target.type === 'id') { + if (!localIds.has(value.target.value) && !referenceIds.has(value.target.value)) { + issues.push(issue('RXH150', `Reference target ID ${value.target.value} does not exist`, valuePath, value)); + } + } else if (!localNames.has(String(value.target.value).toLowerCase())) { + issues.push(issue('RXH151', `Reference target ${value.target.value} does not exist in this database`, valuePath, value)); + } + }, `${path}.entries`); +} + +function validateAccessPoint(node, path, issues) { + const values = node.value.items.filter((item) => item.kind === 'value'); + if (node.id == null) issues.push(issue('RXH160', 'Access points should have an ID so they can be managed securely', `${path}.id`, node, 'warning')); + const key = values[0]; + if (!key || key.valueType !== 'string' || key.value.trim().length < 12) { + issues.push(issue('RXH161', 'Access point API key must be a quoted string of at least 12 characters', `${path}.value.items[0]`, key)); + } + for (let i = 1; i < values.length; i += 1) { + const item = values[i]; + if (item.valueType !== 'string' || !/^\.[A-Za-z0-9+#-]+$/.test(item.value)) { + issues.push(issue('RXH162', 'Allowed languages must be quoted file extensions such as ".js" or ".py"', `${path}.value.items[${i}]`, item)); + } + } +} + +function registerId(map, id, path, node, issues, scope) { + const existing = map.get(id); + if (existing) { + issues.push(issue('RXH113', `Duplicate ID ${id}; IDs must be unique across the ${scope}. First used at ${existing.path}`, `${path}.id`, node)); + } else { + map.set(id, { path, node }); + } +} + +function isAccessPoint(node) { + if (!node || node.kind !== 'assignment' || node.value?.valueType !== 'collection') return false; + const values = node.value.items.filter((item) => item.kind === 'value'); + return values.length >= 2 + && values[0].valueType === 'string' + && values.slice(1).every((item) => item.valueType === 'string' && item.value.startsWith('.')); +} + +function primitive(value) { + if (!value || value.kind !== 'value') return undefined; + return value.valueType === 'nell' ? null : value.value; +} + +function collectionStrings(value) { + if (!value || value.valueType !== 'collection') return null; + const items = value.items.filter((item) => item.kind !== 'comment'); + if (!items.every((item) => item.kind === 'value' && item.valueType === 'string')) return null; + return items.map((item) => item.value); +} + +function walkEntries(entries, visitor, path) { + entries.forEach((entry, index) => { + const itemPath = `${path}[${index}]`; + if (entry.kind === 'assignment') walkValue(entry.value, visitor, `${itemPath}.value`); + }); +} + +function walkValue(value, visitor, path) { + visitor(value, path); + if (value.valueType === 'array' || value.valueType === 'collection') { + value.items.forEach((item, index) => { + const itemPath = `${path}.items[${index}]`; + if (item.kind === 'assignment') walkValue(item.value, visitor, `${itemPath}.value`); + else if (item.kind === 'value') walkValue(item, visitor, itemPath); + }); + } +} + +export function assertValid(document, options = {}) { + const issues = validate(document); + const blocking = issues.filter((entry) => entry.severity === 'error'); + if (blocking.length > 0) throw new RedXaiValidationError(issues); + if (options.warningsAsErrors && issues.length > 0) throw new RedXaiValidationError(issues); + return issues; +} + +export function validateProject(documents) { + const issues = []; + const ids = new Map(); + documents.forEach((document, documentIndex) => { + for (const current of validate(document)) issues.push({ ...current, path: `$project[${documentIndex}]${current.path.slice(1)}` }); + document.databases?.forEach((database, databaseIndex) => { + collectAssignments(database.entries, (assignment, assignmentPath) => { + if (assignment.id == null || assignment.id <= 1) return; + const path = `$project[${documentIndex}].databases[${databaseIndex}]${assignmentPath}`; + const existing = ids.get(assignment.id); + if (existing) issues.push(issue('RXH170', `Project duplicate ID ${assignment.id}; first used at ${existing}`, path, assignment)); + else ids.set(assignment.id, path); + }, '.entries'); + }); + }); + return issues; +} + +function collectDocumentIds(document) { + const ids = new Set(); + document.databases.forEach((database) => { + collectAssignments(database.entries, (assignment) => { + if (Number.isInteger(assignment.id) && assignment.id > 1) ids.add(assignment.id); + }, '.entries'); + }); + return ids; +} + +function collectAssignments(entries, callback, path) { + entries.forEach((entry, index) => { + if (entry.kind !== 'assignment') return; + const currentPath = `${path}[${index}]`; + callback(entry, currentPath); + if (entry.value.valueType === 'array') collectAssignments(entry.value.items, callback, `${currentPath}.value.items`); + }); +} + +export { isAccessPoint }; diff --git a/test/database.test.js b/test/database.test.js new file mode 100644 index 0000000..9502e37 --- /dev/null +++ b/test/database.test.js @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parse, RedXaiDatabase, toPlainValue } from '../src/index.js'; + +const source = `{RedXaiStore[ID=1]=[ +{Root[ID=2]}=;[ + {Name[ID=3]}="Alpha", + {NestedArray[ID=4]}=;[{Enabled[ID=5]}=TRUE]: +]:, +{Count[ID=6]}=1 +]};`; + +test('database indexes nested IDs at database scope', () => { + const db = new RedXaiDatabase(parse(source)); + assert.equal(db.getById(5).name, 'Enabled'); + assert.equal(db.getByName('name')[0].id, 3); +}); + +test('set and rename update indexed values', () => { + const db = new RedXaiDatabase(parse(source)); + db.setById(6, 42); + db.renameById(3, 'DisplayName'); + assert.equal(db.getById(6).value.value, 42); + assert.equal(db.getByName('displayname')[0].id, 3); +}); + +test('move relocates an assignment into another array', () => { + const db = new RedXaiDatabase(parse(source)); + db.moveById(6, 4); + assert.equal(db.requireById(4).value.items.at(-1).id, 6); +}); + +test('copy assigns fresh IDs to copied subtree', () => { + const db = new RedXaiDatabase(parse(source)); + const result = db.copyById(4, { newName: 'NestedCopy' }); + assert.notEqual(result.node.id, 4); + assert.notEqual(result.node.value.items[0].id, 5); + assert.equal(db.getByName('nestedcopy').length, 1); +}); + +test('delete removes indexed data', () => { + const db = new RedXaiDatabase(parse(source)); + assert.equal(db.deleteById(6), true); + assert.equal(db.getById(6), null); + assert.equal(db.deleteById(6), false); +}); + +test('failed transaction rolls back', () => { + const db = new RedXaiDatabase(parse(source)); + assert.throws(() => db.transaction((draft) => { + draft.setById(6, 99); + draft.add(0, 'Duplicate', 1, 3); + })); + assert.equal(db.getById(6).value.value, 1); +}); + +test('plain conversion turns assignment arrays into objects', () => { + const db = new RedXaiDatabase(parse(source)); + assert.deepEqual(toPlainValue(db.getById(4).value), { Enabled: true }); +}); diff --git a/test/editor.test.js b/test/editor.test.js new file mode 100644 index 0000000..f10e4b0 --- /dev/null +++ b/test/editor.test.js @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { launchEditor } from '../src/editor-server.js'; +import { RedXaiFileStore, createDocument, assignment } from '../src/index.js'; + +test('local editor serves, validates, and saves through the storage engine', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'redxai-editor-')); + const file = join(directory, 'Editor.RedXai'); + const store = new RedXaiFileStore(); + await store.save(file, createDocument({ entries: [assignment('Count', 1, 2)] })); + + const launched = await launchEditor(file, { open: false }); + context.after(() => new Promise((resolve) => launched.server.close(resolve))); + const url = new URL(launched.url); + const token = url.searchParams.get('token'); + + const loadedResponse = await fetch(`${url.origin}/api/document?token=${token}`); + assert.equal(loadedResponse.status, 200); + const loaded = await loadedResponse.json(); + assert.match(loaded.source, /Count/); + + const changed = loaded.source.replace(' = 1', ' = 9'); + const saveResponse = await fetch(`${url.origin}/api/save?token=${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: changed }), + }); + assert.equal(saveResponse.status, 200); + assert.equal((await store.load(file)).database.getById(2).value.value, 9); + + const forbidden = await fetch(`${url.origin}/api/document?token=wrong`); + assert.equal(forbidden.status, 403); +}); diff --git a/test/parser.test.js b/test/parser.test.js new file mode 100644 index 0000000..e5a329a --- /dev/null +++ b/test/parser.test.js @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parse, parseValue, serialize, tokenize } from '../src/index.js'; + +const canonical = `{RedXaiStore[ID=1, Version="0.1"]=[ + << access comment + {Access[ID=5032]} = {"RedX - abcdefghijklmnop", ".js", ".py"}, + {BoolLower[ID=2]} = false, + {BoolUpper[ID=3]} = TRUE, + {Integer[ID=4]} = 25, + {Decimal[ID=5]} = 26.8, + {Percent[ID=6]} = 20%, + {Text[ID=7]} = "this is a string", + {EmptyLater[ID=8]} = NELL$, + {Outer[ID=9]} = ;[ + "inside", + ;[1, FALSE, Nell]:, + {GlobalInside[ID=10]} = "reachable" + ]: +] Metadata={Shared=FALSE, ShareID=NELL, OPFNames=NELL, GA=FALSE, GlobalID=NELL}};`; + +test('lexer recognizes comments, percentages, and RedX punctuation', () => { + const tokens = tokenize('<< hi\n{A[ID=2]}=20%'); + assert.equal(tokens[0].type, 'COMMENT'); + assert.equal(tokens.find((token) => token.type === 'PERCENT').value, '%'); + assert.ok(tokens.some((token) => token.type === 'LBRACKET')); +}); + +test('parser supports all original primitive spellings and nested arrays', () => { + const document = parse(canonical); + const entries = document.databases[0].entries.filter((entry) => entry.kind === 'assignment'); + assert.equal(entries[1].value.value, false); + assert.equal(entries[2].value.value, true); + assert.equal(entries[3].value.value, 25); + assert.equal(entries[4].value.value, 26.8); + assert.equal(entries[5].value.valueType, 'percent'); + assert.equal(entries[6].value.value, 'this is a string'); + assert.equal(entries[7].value.valueType, 'nell'); + assert.equal(entries[8].value.valueType, 'array'); + assert.equal(entries[8].value.items[1].valueType, 'array'); + assert.equal(entries[8].value.items[2].id, 10); +}); + +test('parser accepts compact IDs and variables without IDs', () => { + const source = `{RedXaiStore[ID=1]=[{A[2]}=1,{B[]}="local"]FALSE,NELL,NELL,FALSE,NELL};`; + const entries = parse(source).databases[0].entries; + assert.equal(entries[0].id, 2); + assert.equal(entries[1].id, null); +}); + +test('parser accepts original positional metadata', () => { + const source = `{RedXaiStore[ID=1]=[{A[ID=2]}=1]TRUE,508173,OPFNames={"Game1","Game2"},GA=TRUE,ID=23001};`; + const metadata = parse(source).databases[0].metadata; + assert.equal(metadata.syntax, 'legacy'); + assert.equal(metadata.shared.value, true); + assert.equal(metadata.shareId.value, 508173); + assert.equal(metadata.allowedProjectFiles.items[0].value, 'Game1'); + assert.equal(metadata.globalAccess.value, true); + assert.equal(metadata.globalId.value, 23001); +}); + +test('line and multiline comments are retained', () => { + const source = `{RedXaiStore[ID=1]=[ +<< line comment +<< multiline begins +and continues +>> +{A[ID=2]}=1 +]};`; + const entries = parse(source).databases[0].entries; + assert.equal(entries[0].style, 'line'); + assert.equal(entries[1].style, 'block'); + assert.match(entries[1].value, /continues/); +}); + +test('serializer produces canonical named metadata and round-trips', () => { + const first = parse(canonical); + const output = serialize(first); + assert.match(output, /Metadata=\{/); + assert.match(output, /GlobalID=NELL/); + const second = parse(output); + assert.equal(second.databases[0].entries.filter((entry) => entry.kind === 'assignment').length, 9); +}); + +test('parseValue parses isolated SDK values', () => { + assert.deepEqual(parseValue('TRUE').value, true); + assert.equal(parseValue('12.5%').valueType, 'percent'); + assert.equal(parseValue(';[1,"two",NELL]:').items.length, 3); +}); + +test('database header name and ID syntax are exact', () => { + assert.throws(() => parse('{redxaistore[ID=1]=[{A[ID=2]}=1]};'), /exactly RedXaiStore/); + assert.throws(() => parse('{RedXaiStore[1]=[{A[ID=2]}=1]};'), /header property/); +}); + +test('invalid characters produce location-aware syntax errors', () => { + assert.throws(() => parse('{RedXaiStore[ID=1]=[{A[ID=2]}=`bad`]};'), /Unexpected character.*1:/); +}); diff --git a/test/store.test.js b/test/store.test.js new file mode 100644 index 0000000..f3c71ca --- /dev/null +++ b/test/store.test.js @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { RedXaiFileStore, createDocument, assignment } from '../src/index.js'; + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'redxaihm-')); + return { directory, file: join(directory, 'Accounts.RedXai') }; +} + +test('file store saves and loads exact .RedXai files', async () => { + const { file } = await fixture(); + const document = createDocument({ entries: [assignment('Email', 'a@example.com', 2)] }); + const store = new RedXaiFileStore(); + const saved = await store.save(file, document); + assert.ok(saved.bytes > 0); + const loaded = await store.load(file); + assert.equal(loaded.database.getById(2).value.value, 'a@example.com'); +}); + +test('second save creates a backup', async () => { + const { file } = await fixture(); + const store = new RedXaiFileStore(); + const document = createDocument({ entries: [assignment('Count', 1, 2)] }); + await store.save(file, document); + document.databases[0].entries[0].value.value = 2; + await store.save(file, document); + await access(`${file}.bak`); + assert.match(await readFile(`${file}.bak`, 'utf8'), / = 1/); +}); + +test('invalid primary file can recover from validated backup', async () => { + const { file } = await fixture(); + const store = new RedXaiFileStore(); + const document = createDocument({ entries: [assignment('Count', 1, 2)] }); + await store.save(file, document); + document.databases[0].entries[0].value.value = 2; + await store.save(file, document); + await writeFile(file, 'corrupt', 'utf8'); + const loaded = await store.load(file); + assert.equal(loaded.database.getById(2).value.value, 1); + assert.equal(loaded.recoveredFrom, `${file}.bak`); +}); + +test('file store rejects the wrong extension', async () => { + const { directory } = await fixture(); + const store = new RedXaiFileStore(); + await assert.rejects(() => store.save(join(directory, 'bad.redxai'), createDocument()), /exact \.RedXai/); +}); + +test('file transaction persists SDK changes', async () => { + const { file } = await fixture(); + const store = new RedXaiFileStore(); + await store.save(file, createDocument({ entries: [assignment('Count', 1, 2)] })); + await store.transaction(file, (db) => db.setById(2, 77)); + const loaded = await store.load(file); + assert.equal(loaded.database.getById(2).value.value, 77); +}); diff --git a/test/validator.test.js b/test/validator.test.js new file mode 100644 index 0000000..83730c0 --- /dev/null +++ b/test/validator.test.js @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parse, validate, validateProject, assertValid } from '../src/index.js'; + +function codes(source) { + return validate(parse(source)).map((item) => item.code); +} + +test('valid canonical account database has no validation errors', () => { + const source = `{RedXaiStore[ID=1]=[ +{Access[ID=9]}={"RedX - abcdefghijklmnop", ".js"}, +{Accounts[ID=10]}=;[{Email[ID=11]}="a@example.com"]: +]FALSE,NELL,NELL,FALSE,NELL};`; + assert.equal(validate(parse(source)).filter((item) => item.severity === 'error').length, 0); +}); + +test('database header must be ID 1', () => { + assert.ok(codes('{RedXaiStore[ID=2]=[{A[ID=3]}=1]};').includes('RXH101')); +}); + +test('0 and 1 are invalid variable IDs', () => { + const result = codes('{RedXaiStore[ID=1]=[{A[ID=0]}=1,{B[ID=1]}=2]};'); + assert.equal(result.filter((code) => code === 'RXH112').length, 2); +}); + +test('duplicate IDs are rejected across nested arrays and databases', () => { + const source = `{RedXaiStore[ID=1]=[{A[ID=2]}=;[{Nested[ID=7]}=1]:]}; +{RedXaiStore[ID=1]=[{Again[ID=7]}=2]};`; + assert.ok(codes(source).includes('RXH113')); + assert.throws(() => assertValid(parse(source)), /validation failed/i); +}); + +test('access points must appear before ordinary values', () => { + const source = `{RedXaiStore[ID=1]=[ +{Data[ID=2]}=1, +{LateAccess[ID=3]}={"RedX - abcdefghijklmnop", ".js"} +]};`; + assert.ok(codes(source).includes('RXH130')); +}); + +test('shared database requires share metadata and file names', () => { + const source = `{RedXaiStore[ID=1]=[{A[ID=2]}=1] Metadata={Shared=TRUE,ShareID=NELL,OPFNames=NELL,GA=FALSE,GlobalID=NELL}};`; + const result = codes(source); + assert.ok(result.includes('RXH142')); + assert.ok(result.includes('RXH143')); +}); + +test('global database requires numeric ID greater than 1', () => { + const source = `{RedXaiStore[ID=1]=[{A[ID=2]}=1] Metadata={Shared=FALSE,ShareID=NELL,OPFNames=NELL,GA=TRUE,GlobalID=1}};`; + assert.ok(codes(source).includes('RXH144')); +}); + +test('unresolved references are rejected', () => { + const source = `{RedXaiStore[ID=1]=[{A[ID=2]}=@999,{B[ID=3]}=@Missing]};`; + const result = codes(source); + assert.ok(result.includes('RXH150')); + assert.ok(result.includes('RXH151')); +}); + +test('forward references can target IDs declared in a later database', () => { + const source = `{RedXaiStore[ID=1]=[{Pointer[ID=2]}=@50]};\n{RedXaiStore[ID=1]=[{Later[ID=50]}="ok"]};`; + assert.ok(!codes(source).includes('RXH150')); +}); + +test('project validator rejects IDs reused in separate files', () => { + const one = parse('{RedXaiStore[ID=1]=[{A[ID=20]}=1]};'); + const two = parse('{RedXaiStore[ID=1]=[{B[ID=20]}=2]};'); + assert.ok(validateProject([one, two]).some((item) => item.code === 'RXH170')); +});