agnets done
This commit is contained in:
@@ -11,16 +11,19 @@
|
||||
*
|
||||
* React → base44Client.js → HTTP → Go API → PostgreSQL
|
||||
*
|
||||
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
|
||||
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
|
||||
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
|
||||
* of the demo user's default preferences, which the synchronous accessor below
|
||||
* needs before the first response arrives.
|
||||
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
|
||||
* loaded by the backend's `make seed`, and nothing in the running app carries a
|
||||
* copy of it: `store.js` — the localStorage database this replaced — is gone,
|
||||
* and `seed.js` is now a test fixture that no production module imports.
|
||||
*
|
||||
* The one thing still needed before the first response arrives is the *shape*
|
||||
* of the user's default preferences, because the accessor that reads them is
|
||||
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
|
||||
*/
|
||||
|
||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER } from './seed';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
@@ -33,6 +36,14 @@ const ENTITY_NAMES = [
|
||||
/* Shifts worked, missed and overrun. The operational record behind
|
||||
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
||||
'ShiftRecord',
|
||||
/* The agent and skill registry the runtime resolves from.
|
||||
Authored definitions used to be written into the account's preferences,
|
||||
which the backend stores faithfully and the runtime never reads — so an
|
||||
agent created in the editor showed as "published" and answered every
|
||||
request with 404. These are the endpoints that make an authored agent a
|
||||
real one. */
|
||||
'AgentDefinition',
|
||||
'SkillDefinition',
|
||||
];
|
||||
|
||||
const entities = Object.fromEntries(
|
||||
@@ -210,7 +221,7 @@ const auth = {
|
||||
* Preferences, read synchronously.
|
||||
*
|
||||
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||
* from `seed.js` so a key the server has never stored still resolves. See the
|
||||
* from `demoUser.js` so a key the server has never stored still resolves. See the
|
||||
* note on `currentUser` for why this must not become async.
|
||||
*/
|
||||
preferences() {
|
||||
@@ -327,6 +338,41 @@ const workflows = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Owliver ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* What could usefully be asked on this page.
|
||||
*
|
||||
* The one read in this file that is not a table. `GET /owliver/suggestions`
|
||||
* answers with at most three questions, and the server decides all three: it
|
||||
* filters them against the caller's role through the same policy table every
|
||||
* other endpoint consults, and — when nothing has been typed — ranks them
|
||||
* against the organization's actual state in PostgreSQL. A workspace with
|
||||
* unfinished drafts is asked about drafts; one with unscored candidates is
|
||||
* asked about screening.
|
||||
*
|
||||
* Nothing is ranked, scored, filtered or reordered on this side. That is the
|
||||
* point: a suggestion is a claim that the reader could usefully ask something,
|
||||
* and the only thing that knows whether that is true is the thing holding the
|
||||
* data and the permissions. The panel requests, renders, and runs whichever one
|
||||
* is chosen through the capability it names.
|
||||
*
|
||||
* `query` is what is in the composer. Sent only when it is non-empty: an absent
|
||||
* query asks the data what to suggest, and an empty string would be a different
|
||||
* request from the one the caller means.
|
||||
*/
|
||||
const owliver = {
|
||||
/** @param {any} request */
|
||||
async suggestions({ page, query = '' } = {}) {
|
||||
if (!page) return [];
|
||||
const typed = String(query || '').trim();
|
||||
const data = await request('GET', '/owliver/suggestions', {
|
||||
query: { page, ...(typed ? { query: typed } : {}) },
|
||||
});
|
||||
return Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
||||
|
||||
const integrations = {
|
||||
@@ -344,7 +390,7 @@ const analytics = {
|
||||
},
|
||||
};
|
||||
|
||||
export const base44 = { entities, auth, integrations, analytics, workflows };
|
||||
export const base44 = { entities, auth, integrations, analytics, workflows, owliver };
|
||||
|
||||
/** Where the entity data actually comes from, for diagnostics. */
|
||||
export { API_BASE_URL };
|
||||
|
||||
39
src/api/demoUser.js
Normal file
39
src/api/demoUser.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The shape of a signed-in user, before the server has answered.
|
||||
*
|
||||
* This is a **default shape, not a record**. The user lives in PostgreSQL and
|
||||
* arrives from `GET /me`; what is here is the set of keys that must resolve
|
||||
* during the first render, in the fraction of a second before that response
|
||||
* lands.
|
||||
*
|
||||
* It exists as its own module for one reason: `base44Client.js` needs exactly
|
||||
* this, and it used to reach into `api/seed.js` to get it — a 1,900-line
|
||||
* fixture of demo positions, candidates, interviews, staff and shift records,
|
||||
* every byte of which was then in the production bundle so that three booleans
|
||||
* could be defaulted. The fixture is still the right thing for the test scripts
|
||||
* that read it (`scripts/skill-check.mjs`, `scripts/owliver-capture.mjs`); it
|
||||
* was never the right thing for the running app. `seed.js` re-exports this
|
||||
* constant, so those scripts are unchanged and the production import chain no
|
||||
* longer reaches them.
|
||||
*
|
||||
* Nothing here is a source of truth for anything. `preferences` is the one part
|
||||
* that is read: `auth.preferences()` is synchronous — `AssistantPanelContext`
|
||||
* decides whether Owliver starts open in a `useState` initialiser — so a key
|
||||
* the server has never stored still has to resolve to something rather than to
|
||||
* `undefined`.
|
||||
*/
|
||||
export const DEMO_USER = {
|
||||
id: 'user_demo',
|
||||
full_name: 'Alex Rivera',
|
||||
email: 'demo@krow.app',
|
||||
role: 'admin',
|
||||
account_type: 'employer',
|
||||
created_date: '2026-06-01T09:00:00.000Z',
|
||||
/* Product preferences travel with the account rather than in a store of their
|
||||
own, so there is one record to persist and one thing to read. */
|
||||
preferences: {
|
||||
owliverDefault: true,
|
||||
compactDensity: false,
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
@@ -109,6 +109,11 @@ const RESOURCE_PATHS = {
|
||||
User: 'users',
|
||||
Assignment: 'assignments',
|
||||
ShiftRecord: 'shift-records',
|
||||
/* Authored agents and skills. These are the registry the BACKEND runs from:
|
||||
an agent saved here is one Owliver can be asked to run, which is the whole
|
||||
difference between this and the preferences blob these used to live in. */
|
||||
AgentDefinition: 'agent-definitions',
|
||||
SkillDefinition: 'skill-definitions',
|
||||
};
|
||||
|
||||
/* ── Request ────────────────────────────────────────────────────────────── */
|
||||
|
||||
199
src/api/seed.js
199
src/api/seed.js
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { SHIFT_RECORDS } from './attendanceSeed';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
|
||||
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||
|
||||
@@ -530,9 +531,14 @@ const JOB_APPLICATIONS = [
|
||||
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
||||
}),
|
||||
unscreened({
|
||||
/* The rejected outcome, and the only one reached without a score: turned
|
||||
down on the licence requirement before screening ran. Rejection overwrites
|
||||
whatever stage it was reached from, which is why the funnel can place him
|
||||
no further than screened. */
|
||||
id: 'app_kevin', applicant_name: 'Kevin Boyle', email: 'kevin.boyle@email.com',
|
||||
phone: '+1-415-555-0113', years_experience: 7, english_level: 'native',
|
||||
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
||||
status: 'rejected', updated_date: iso('2026-08-07'),
|
||||
}),
|
||||
unscreened({
|
||||
id: 'app_rosa', applicant_name: 'Rosa Delgado', email: 'rosa.delgado@email.com',
|
||||
@@ -563,7 +569,11 @@ const JOB_APPLICATIONS = [
|
||||
selfie_url: 'https://i.pravatar.cc/240?img=33',
|
||||
job_posting_id: 'job_bartender_corp',
|
||||
job_title: 'Experienced Bartender – Corporate Events',
|
||||
status: 'hired',
|
||||
/* Hired and then rostered. `assigned` is the seventh application status and
|
||||
the last one nothing exercised: it counts as hired everywhere the product
|
||||
asks how many people a role has, but takes the candidate out of the
|
||||
running, and it is the only status backed by a row in Assignment. */
|
||||
status: 'assigned',
|
||||
ai_score: 92,
|
||||
ai_match_label: 'Excellent Match',
|
||||
ai_summary:
|
||||
@@ -583,6 +593,9 @@ const JOB_APPLICATIONS = [
|
||||
|
||||
/* Event Server – Fine Dining: 3 applicants · 3 screened · 0 hired */
|
||||
{
|
||||
/* Shortlisted: the strongest screened candidate on this role, moved forward
|
||||
but not yet interviewed. Without her the shortlisted stage was empty and
|
||||
every funnel that counts it reported a permanent zero. */
|
||||
id: 'app_sofia',
|
||||
applicant_name: 'Sofia Mendez',
|
||||
email: 'sofia.mendez@email.com',
|
||||
@@ -599,7 +612,7 @@ const JOB_APPLICATIONS = [
|
||||
selfie_url: 'https://i.pravatar.cc/240?img=47',
|
||||
job_posting_id: 'job_server_fine',
|
||||
job_title: 'Event Server – Fine Dining',
|
||||
status: 'ai_screened',
|
||||
status: 'shortlisted',
|
||||
ai_score: 89,
|
||||
ai_match_label: 'Excellent Match',
|
||||
ai_summary:
|
||||
@@ -1426,9 +1439,18 @@ const WORKER_PROFILES = [
|
||||
salary_expectations: '$30–$36/hr',
|
||||
leadership_potential: 92,
|
||||
ai_interview_score: 93,
|
||||
krow_score: 94,
|
||||
reliability_score: 95,
|
||||
profile_completion: 100,
|
||||
krow_score: 95,
|
||||
reliability_score: 96,
|
||||
profile_completion: 94,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: 98, performance: 93, education: 100,
|
||||
clientReviews: 98, supervisorReviews: 96,
|
||||
growth: 100, experience: 80,
|
||||
},
|
||||
xp: 1480,
|
||||
/* Seven years on the floor, as a completed ladder: Server and Leadership to
|
||||
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
|
||||
@@ -1479,9 +1501,18 @@ const WORKER_PROFILES = [
|
||||
industries: ['Hospitality'],
|
||||
leadership_potential: 24,
|
||||
ai_interview_score: 0,
|
||||
krow_score: 12,
|
||||
reliability_score: 38,
|
||||
profile_completion: 62,
|
||||
krow_score: 30,
|
||||
reliability_score: 37,
|
||||
profile_completion: 88,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: 92, performance: 0, education: 80,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 12, experience: 15,
|
||||
},
|
||||
xp: 120,
|
||||
/* One year in: the Beginner rung of Server, and one Intermediate module
|
||||
started — so her card reads "Beginner, advancing" rather than a flat
|
||||
@@ -1537,9 +1568,18 @@ const WORKER_PROFILES = [
|
||||
salary_expectations: '$26–$32/hr',
|
||||
leadership_potential: 68,
|
||||
ai_interview_score: 86,
|
||||
krow_score: 82,
|
||||
krow_score: 87,
|
||||
reliability_score: 91,
|
||||
profile_completion: 94,
|
||||
profile_completion: 88,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: 96, performance: 88, education: 100,
|
||||
clientReviews: 94, supervisorReviews: 92,
|
||||
growth: 100, experience: 45,
|
||||
},
|
||||
xp: 1180,
|
||||
/* Server through Intermediate, plus two of the three Advanced modules —
|
||||
Fine Dining Service Standards is the one thing between him and Advanced,
|
||||
@@ -1585,9 +1625,18 @@ const WORKER_PROFILES = [
|
||||
industries: ['Hospitality'],
|
||||
leadership_potential: 45,
|
||||
ai_interview_score: 74,
|
||||
krow_score: 68,
|
||||
reliability_score: 84,
|
||||
profile_completion: 80,
|
||||
krow_score: 78,
|
||||
reliability_score: 86,
|
||||
profile_completion: 88,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: 93, performance: 81, education: 100,
|
||||
clientReviews: 90, supervisorReviews: 88,
|
||||
growth: 76, experience: 25,
|
||||
},
|
||||
xp: 760,
|
||||
completed_courses: completions(
|
||||
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
|
||||
@@ -1624,9 +1673,18 @@ const WORKER_PROFILES = [
|
||||
industries: ['Hospitality'],
|
||||
leadership_potential: 20,
|
||||
ai_interview_score: 0,
|
||||
krow_score: 41,
|
||||
reliability_score: 72,
|
||||
profile_completion: 58,
|
||||
krow_score: 35,
|
||||
reliability_score: 42,
|
||||
profile_completion: 63,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: 100, performance: 0, education: 100,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 32, experience: 0,
|
||||
},
|
||||
xp: 320,
|
||||
completed_courses: completions(
|
||||
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
|
||||
@@ -1665,7 +1723,16 @@ const WORKER_PROFILES = [
|
||||
ai_interview_score: 0,
|
||||
krow_score: 0,
|
||||
reliability_score: 0,
|
||||
profile_completion: 30,
|
||||
profile_completion: 56,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: null, performance: 0, education: 0,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 0, experience: 0,
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
@@ -1700,7 +1767,16 @@ const WORKER_PROFILES = [
|
||||
ai_interview_score: 0,
|
||||
krow_score: 0,
|
||||
reliability_score: 0,
|
||||
profile_completion: 10,
|
||||
profile_completion: 31,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: null, performance: 0, education: 0,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 0, experience: 0,
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
@@ -1735,7 +1811,16 @@ const WORKER_PROFILES = [
|
||||
ai_interview_score: 0,
|
||||
krow_score: 0,
|
||||
reliability_score: 0,
|
||||
profile_completion: 25,
|
||||
profile_completion: 56,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: null, performance: 0, education: 0,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 0, experience: 0,
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
@@ -1770,7 +1855,16 @@ const WORKER_PROFILES = [
|
||||
ai_interview_score: 0,
|
||||
krow_score: 0,
|
||||
reliability_score: 0,
|
||||
profile_completion: 20,
|
||||
profile_completion: 56,
|
||||
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||
this profile. Seeding the engine's own output is what makes the
|
||||
"auto-recalculated" claim true — completing one course no longer
|
||||
jumps the score to a different number than the one on screen. */
|
||||
score_breakdown: {
|
||||
attendance: null, performance: 0, education: 0,
|
||||
clientReviews: 0, supervisorReviews: 0,
|
||||
growth: 0, experience: 0,
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
@@ -1878,33 +1972,56 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
|
||||
|
||||
/* ── Signed-in demo user ───────────────────────────────────────────────── */
|
||||
|
||||
export const DEMO_USER = {
|
||||
id: 'user_demo',
|
||||
full_name: 'Alex Rivera',
|
||||
email: 'demo@krow.app',
|
||||
role: 'admin',
|
||||
account_type: 'employer',
|
||||
created_date: iso('2026-06-01'),
|
||||
/* Product preferences travel with the account rather than in a store of their
|
||||
own, so there is one record to persist and one thing to read. */
|
||||
preferences: {
|
||||
owliverDefault: true,
|
||||
compactDensity: false,
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Re-exported, not declared.
|
||||
*
|
||||
* The default user shape moved to `api/demoUser.js` so that `base44Client.js`
|
||||
* can have it without importing this file — and with it every position,
|
||||
* candidate, interview and shift record below — into the production bundle.
|
||||
* The fixtures here are for the test scripts, and they still read
|
||||
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
||||
*/
|
||||
export { DEMO_USER } from './demoUser';
|
||||
|
||||
/* ── Assignments ───────────────────────────────────────────────────────────
|
||||
Who is on which position, and until when — the record that turns a hire into
|
||||
workforce allocation, and the one that says who is therefore not free for
|
||||
anything else.
|
||||
|
||||
Empty by design. This deployment has no assignment records yet: they are
|
||||
written by the assignment flow, not shipped as seed. The engine reads an
|
||||
empty set correctly — every position simply reports nobody assigned — and
|
||||
the UI omits the demand figures rather than inventing coverage that does
|
||||
not exist. */
|
||||
const ASSIGNMENTS = [];
|
||||
Was empty by design, on the reasoning that assignments are written by the
|
||||
assignment flow rather than shipped. That held until it turned out to be the
|
||||
reason nothing exercised `assigned`: the funnel dropped that status out of
|
||||
every stage bucket, and the agent's own candidate lookup offered somebody
|
||||
already working a shift as a person to chase. Neither was visible against a
|
||||
fixture that never produced one.
|
||||
|
||||
So: exactly one, the minimum that makes the status real. Every reader still
|
||||
has to handle the empty case, because eight of the nine positions have no
|
||||
assignment and report nobody assigned. */
|
||||
const ASSIGNMENTS = [
|
||||
/* One assignment, for the one application at `assigned`.
|
||||
Marco is the right person to carry it: he is the control in the attendance
|
||||
data, with a recurring corporate bartending pattern across the whole
|
||||
window, so an open-ended assignment to that position is what his shift
|
||||
records already describe. `ends_at` is null — the assignment is ongoing,
|
||||
which is also the case the readers have to handle.
|
||||
`worker_profile_id` is absent by design: Marco is Staff, and the talent
|
||||
pool and the roster are deliberately different people. */
|
||||
{
|
||||
id: 'assign_marco_bartender',
|
||||
job_posting_id: 'job_bartender_corp',
|
||||
application_id: 'app_marco',
|
||||
worker_email: 'marco.rivera@email.com',
|
||||
worker_name: 'Marco Rivera',
|
||||
starts_at: iso('2026-07-01'),
|
||||
ends_at: null,
|
||||
status: 'active',
|
||||
source: 'seed',
|
||||
match_score: 92,
|
||||
created_date: iso('2026-07-01'),
|
||||
updated_date: iso('2026-07-01'),
|
||||
},
|
||||
];
|
||||
|
||||
/* Attendance lives in its own module: it is generated rather than written out,
|
||||
and its dates are anchored to now rather than to this file's fixed calendar.
|
||||
|
||||
173
src/api/store.js
173
src/api/store.js
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* In-memory entity store backing the KROW demo.
|
||||
*
|
||||
* Mirrors the Base44 entity API the app was built against
|
||||
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
|
||||
* page and component consumes data exactly as it did against the real backend.
|
||||
* Records live in memory and are mirrored to localStorage, so demo edits —
|
||||
* screening a candidate, hiring, creating a position — survive a reload.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'krow_demo_db';
|
||||
const STORAGE_VERSION = 8;
|
||||
|
||||
/** Simulated network latency, in ms, so loading states are real. */
|
||||
const LATENCY = { read: 140, write: 220 };
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Structured clone with a JSON fallback for older Safari. */
|
||||
const clone = (value) =>
|
||||
typeof structuredClone === 'function'
|
||||
? structuredClone(value)
|
||||
: JSON.parse(JSON.stringify(value));
|
||||
|
||||
let _counter = 0;
|
||||
export function makeId(prefix = 'rec') {
|
||||
_counter += 1;
|
||||
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Persistence ───────────────────────────────────────────────────────── */
|
||||
|
||||
let db = {};
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
|
||||
} catch {
|
||||
// Private browsing / quota — the demo still works from memory.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
|
||||
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the store: persisted snapshot if one matches the current seed version,
|
||||
* otherwise the seed itself.
|
||||
*/
|
||||
export function initStore(seed) {
|
||||
db = readPersisted() || clone(seed);
|
||||
// A seed that gained an entity after the snapshot was written still resolves.
|
||||
for (const [name, records] of Object.entries(seed)) {
|
||||
if (!db[name]) db[name] = clone(records);
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Drops all local edits and restores the shipped demo data. */
|
||||
export function resetStore(seed) {
|
||||
db = clone(seed);
|
||||
persist();
|
||||
}
|
||||
|
||||
/* ── Query helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Applies a Base44-style sort string: `'-ai_score'` descending,
|
||||
* `'created_date'` ascending. Unknown fields leave order untouched.
|
||||
*/
|
||||
function applySort(records, sort) {
|
||||
if (!sort) return records;
|
||||
const desc = sort.startsWith('-');
|
||||
const field = desc ? sort.slice(1) : sort;
|
||||
return [...records].sort((a, b) => {
|
||||
const av = a?.[field];
|
||||
const bv = b?.[field];
|
||||
if (av === bv) return 0;
|
||||
if (av === undefined || av === null) return 1;
|
||||
if (bv === undefined || bv === null) return -1;
|
||||
const result = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return desc ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Shallow equality match across every key of `query`, array fields included. */
|
||||
function matches(record, query) {
|
||||
return Object.entries(query).every(([key, want]) => {
|
||||
const got = record?.[key];
|
||||
if (Array.isArray(want)) return want.includes(got);
|
||||
return got === want;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Entity API ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds the client surface for one entity. Every method is async and returns
|
||||
* cloned records, so callers can never mutate the store by reference.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const table = () => (db[name] ||= []);
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
|
||||
async list(sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
return clone(applySort(table(), sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().filter((record) => matches(record, query));
|
||||
return clone(applySort(found, sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().find((record) => record.id === id);
|
||||
if (!found) throw new Error(`${name} ${id} not found`);
|
||||
return clone(found);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
await delay(LATENCY.write);
|
||||
const now = new Date().toISOString();
|
||||
const record = {
|
||||
id: makeId(name.toLowerCase()),
|
||||
created_date: now,
|
||||
updated_date: now,
|
||||
...data,
|
||||
};
|
||||
table().unshift(record);
|
||||
persist();
|
||||
return clone(record);
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
await delay(LATENCY.write);
|
||||
const index = table().findIndex((record) => record.id === id);
|
||||
if (index === -1) throw new Error(`${name} ${id} not found`);
|
||||
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
|
||||
table()[index] = next;
|
||||
persist();
|
||||
return clone(next);
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await delay(LATENCY.write);
|
||||
db[name] = table().filter((record) => record.id !== id);
|
||||
persist();
|
||||
return { id };
|
||||
},
|
||||
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user