vanaheimr 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/clientImageToDataUrl.ts +141 -0
- package/lib/content/gameContent.ts +106 -0
- package/lib/corsPublicRead.ts +19 -0
- package/lib/db/studioBlobStore.ts +286 -0
- package/lib/db/studioLookup.ts +39 -0
- package/lib/enemyTypes.ts +13 -0
- package/lib/entityClassStorage.ts +89 -0
- package/lib/entityRegistryStorage.ts +75 -0
- package/lib/entityRuntimeBundle.ts +31 -0
- package/lib/entityTypes.ts +60 -0
- package/lib/entry.ts +20 -0
- package/lib/equipmentStorage.ts +68 -0
- package/lib/equipmentTypes.ts +14 -0
- package/lib/generated/ally.ts +18 -0
- package/lib/generated/enemy.ts +23 -0
- package/lib/sdk/allyMap.ts +37 -0
- package/lib/sdk/allyProps.ts +72 -0
- package/lib/sdk/index.ts +17 -0
- package/lib/sdk/node.ts +11 -0
- package/lib/sdk/parseStore.ts +31 -0
- package/lib/storage.ts +57 -0
- package/lib/supabase/admin.ts +28 -0
- package/lib/supabase/client.ts +8 -0
- package/lib/supabase/middleware.ts +50 -0
- package/lib/supabase/server.ts +28 -0
- package/lib/tools.ts +42 -0
- package/lib/types.ts +37 -0
- package/package.json +50 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BlobLane,
|
|
3
|
+
isBlobStoreConfigured,
|
|
4
|
+
readEntityRegistryFromBlob,
|
|
5
|
+
writeEntityRegistryToBlob,
|
|
6
|
+
} from "./db/studioBlobStore";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import {
|
|
9
|
+
defaultEntityRegistry,
|
|
10
|
+
entityClassSlugFromName,
|
|
11
|
+
type EntityClassRegistry,
|
|
12
|
+
} from "./entityTypes";
|
|
13
|
+
|
|
14
|
+
function requireStudioId(studioId: string | undefined, op: string): asserts studioId is string {
|
|
15
|
+
if (!studioId?.trim()) {
|
|
16
|
+
throw new Error(`${op}: studioId is required.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function requireRemoteStorage(op: string): void {
|
|
21
|
+
if (!isBlobStoreConfigured()) {
|
|
22
|
+
throw new Error(`${op}: Remote storage is not configured.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeEntityRegistry(registry: EntityClassRegistry): EntityClassRegistry {
|
|
27
|
+
const classes = Array.isArray(registry.classes) ? registry.classes : [];
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const c of classes) {
|
|
31
|
+
const name = typeof c.name === "string" ? c.name.trim() : "";
|
|
32
|
+
if (!name) continue;
|
|
33
|
+
const slug = entityClassSlugFromName(name);
|
|
34
|
+
if (!slug || seen.has(slug)) continue;
|
|
35
|
+
seen.add(slug);
|
|
36
|
+
out.push({
|
|
37
|
+
id: typeof c.id === "string" && c.id.trim() ? c.id : randomUUID(),
|
|
38
|
+
slug,
|
|
39
|
+
name,
|
|
40
|
+
recordLabelSingular:
|
|
41
|
+
typeof c.recordLabelSingular === "string" && c.recordLabelSingular.trim()
|
|
42
|
+
? c.recordLabelSingular.trim()
|
|
43
|
+
: undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { classes: out };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readEntityRegistry(
|
|
50
|
+
studioId?: string,
|
|
51
|
+
lane: BlobLane = "draft"
|
|
52
|
+
): Promise<EntityClassRegistry> {
|
|
53
|
+
requireStudioId(studioId, "readEntityRegistry");
|
|
54
|
+
requireRemoteStorage("readEntityRegistry");
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const fromBlob = await readEntityRegistryFromBlob(studioId, lane);
|
|
58
|
+
if (fromBlob) return normalizeEntityRegistry(fromBlob);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error("[entityRegistryStorage] read failed", err);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return defaultEntityRegistry();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function writeEntityRegistry(
|
|
68
|
+
registry: EntityClassRegistry,
|
|
69
|
+
studioId?: string,
|
|
70
|
+
lane: BlobLane = "draft"
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
requireStudioId(studioId, "writeEntityRegistry");
|
|
73
|
+
requireRemoteStorage("writeEntityRegistry");
|
|
74
|
+
await writeEntityRegistryToBlob(studioId, normalizeEntityRegistry(registry), lane);
|
|
75
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readEntityClassStore } from "./entityClassStorage";
|
|
2
|
+
import { readEntityRegistry } from "./entityRegistryStorage";
|
|
3
|
+
import type { EntityClass } from "./entityTypes";
|
|
4
|
+
import { buildAllyByPrimaryKey } from "./sdk/allyMap";
|
|
5
|
+
import type { AllyTree } from "./sdk/allyMap";
|
|
6
|
+
import type { SchemaField } from "./types";
|
|
7
|
+
import type { BlobLane } from "./db/studioBlobStore";
|
|
8
|
+
|
|
9
|
+
export type EntityRuntimeBundle = {
|
|
10
|
+
classes: EntityClass[];
|
|
11
|
+
entities: Record<string, AllyTree>;
|
|
12
|
+
playerSchemas: Record<string, SchemaField[]>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Published (live) entity classes and PK-keyed rows for game runtime. */
|
|
16
|
+
export async function fetchEntityRuntimeBundle(
|
|
17
|
+
studioId: string,
|
|
18
|
+
lane: BlobLane = "live"
|
|
19
|
+
): Promise<EntityRuntimeBundle> {
|
|
20
|
+
const registry = await readEntityRegistry(studioId, lane);
|
|
21
|
+
const entities: Record<string, AllyTree> = {};
|
|
22
|
+
const playerSchemas: Record<string, SchemaField[]> = {};
|
|
23
|
+
|
|
24
|
+
for (const c of registry.classes) {
|
|
25
|
+
const store = await readEntityClassStore(studioId, c.slug, lane);
|
|
26
|
+
entities[c.slug] = buildAllyByPrimaryKey(store);
|
|
27
|
+
playerSchemas[c.slug] = store.playerSchema.fields;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { classes: registry.classes, entities, playerSchemas };
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** One authorable entity class (e.g. allies, enemies, NPCs). */
|
|
2
|
+
export type EntityClass = {
|
|
3
|
+
id: string;
|
|
4
|
+
/** Blob filename stem and analytics variant key (e.g. `allies`, `enemies`). */
|
|
5
|
+
slug: string;
|
|
6
|
+
/** Display name in the console (e.g. `Allies`). */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Singular label for buttons (e.g. `ally`). Defaults from name. */
|
|
9
|
+
recordLabelSingular?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EntityClassRegistry = {
|
|
13
|
+
classes: EntityClass[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const BUILTIN_ENTITY_SLUGS = ["allies", "enemies"] as const;
|
|
17
|
+
export type BuiltinEntitySlug = (typeof BUILTIN_ENTITY_SLUGS)[number];
|
|
18
|
+
|
|
19
|
+
export function defaultEntityRegistry(): EntityClassRegistry {
|
|
20
|
+
return { classes: [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Legacy localStorage / analytics keys mapped to class slugs. */
|
|
24
|
+
export const LEGACY_VARIANT_TO_SLUG: Record<string, string> = {
|
|
25
|
+
ally: "allies",
|
|
26
|
+
enemy: "enemies",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SLUG_RE = /^[a-z][a-z0-9_-]{0,47}$/;
|
|
30
|
+
|
|
31
|
+
export function normalizeEntitySlug(raw: string): string | null {
|
|
32
|
+
const s = raw.trim().toLowerCase().replace(/\s+/g, "_");
|
|
33
|
+
if (!SLUG_RE.test(s)) return null;
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Slug = lowercase display name with whitespace replaced by `_`. */
|
|
38
|
+
export function slugFromDisplayName(name: string): string {
|
|
39
|
+
return name
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\s+/g, "_")
|
|
43
|
+
.replace(/_+/g, "_")
|
|
44
|
+
.replace(/^_+|_+$/g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Validated slug derived from display name (for storage / APIs). */
|
|
48
|
+
export function entityClassSlugFromName(name: string): string | null {
|
|
49
|
+
const slug = slugFromDisplayName(name);
|
|
50
|
+
if (!slug) return null;
|
|
51
|
+
return normalizeEntitySlug(slug);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function recordLabelForClass(c: EntityClass): string {
|
|
55
|
+
return c.recordLabelSingular?.trim() || c.name.trim().toLowerCase().replace(/s$/, "") || "record";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pluralRecordsLabel(c: EntityClass): string {
|
|
59
|
+
return c.name.trim() || c.slug;
|
|
60
|
+
}
|
package/lib/entry.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type { AllyProps } from "./sdk/allyProps";
|
|
2
|
+
export type { AllyTree } from "./sdk/allyMap";
|
|
3
|
+
export type { SchemaField } from "./types";
|
|
4
|
+
export type { EntityClass, EntityClassRegistry } from "./entityTypes";
|
|
5
|
+
export {
|
|
6
|
+
getEntityClasses,
|
|
7
|
+
getEntities,
|
|
8
|
+
getEntityPlayerSchema,
|
|
9
|
+
resolveEntityClassSlug,
|
|
10
|
+
getAllies,
|
|
11
|
+
getEnemies,
|
|
12
|
+
getVersion,
|
|
13
|
+
getAllyPlayerSchema,
|
|
14
|
+
getEnemyPlayerSchema,
|
|
15
|
+
getGameContentProvider,
|
|
16
|
+
setGameContentProvider,
|
|
17
|
+
setGameContentState,
|
|
18
|
+
type GameContentProvider,
|
|
19
|
+
type GameContentState,
|
|
20
|
+
} from "./content/gameContent";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EquipmentStore } from "./equipmentTypes";
|
|
2
|
+
import { emptyEquipmentStore } from "./equipmentTypes";
|
|
3
|
+
import {
|
|
4
|
+
type BlobLane,
|
|
5
|
+
isBlobStoreConfigured,
|
|
6
|
+
migrateLegacyToLanes,
|
|
7
|
+
readEquipmentStoreFromBlob,
|
|
8
|
+
writeEquipmentStoreToBlob,
|
|
9
|
+
} from "./db/studioBlobStore";
|
|
10
|
+
|
|
11
|
+
function requireStudioId(studioId: string | undefined, op: string): asserts studioId is string {
|
|
12
|
+
if (!studioId?.trim()) {
|
|
13
|
+
throw new Error(`${op}: studioId is required (enemy data is only stored in remote object storage).`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireRemoteStorage(op: string): void {
|
|
18
|
+
if (!isBlobStoreConfigured()) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`${op}: Remote storage is not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME — or BLOB_READ_WRITE_TOKEN — then restart the dev server or redeploy.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeEquipmentStore(store: EquipmentStore): EquipmentStore {
|
|
26
|
+
return {
|
|
27
|
+
schema: { fields: store.schema.fields },
|
|
28
|
+
playerSchema: { fields: store.playerSchema?.fields ?? [] },
|
|
29
|
+
equipment: Array.isArray(store.equipment) ? store.equipment : [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readEquipmentStore(studioId?: string, lane: BlobLane = "draft"): Promise<EquipmentStore> {
|
|
34
|
+
requireStudioId(studioId, "readEquipmentStore");
|
|
35
|
+
requireRemoteStorage("readEquipmentStore");
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
let fromBlob = await readEquipmentStoreFromBlob(studioId, lane);
|
|
39
|
+
|
|
40
|
+
if (!fromBlob && lane === "draft") {
|
|
41
|
+
const migrated = await migrateLegacyToLanes(studioId);
|
|
42
|
+
if (migrated) fromBlob = await readEquipmentStoreFromBlob(studioId, lane);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (fromBlob && fromBlob.schema && Array.isArray(fromBlob.schema.fields)) {
|
|
46
|
+
if (!Array.isArray(fromBlob.equipment)) {
|
|
47
|
+
return normalizeEquipmentStore({ ...fromBlob, equipment: [] });
|
|
48
|
+
}
|
|
49
|
+
return normalizeEquipmentStore(fromBlob);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("[equipmentStorage] Remote read failed", err);
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return emptyEquipmentStore();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function writeEquipmentStore(
|
|
60
|
+
store: EquipmentStore,
|
|
61
|
+
studioId?: string,
|
|
62
|
+
lane: BlobLane = "draft"
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
requireStudioId(studioId, "writeEquipmentStore");
|
|
65
|
+
requireRemoteStorage("writeEquipmentStore");
|
|
66
|
+
const normalized = normalizeEquipmentStore(store);
|
|
67
|
+
await writeEquipmentStoreToBlob(studioId, normalized, lane);
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AllyRow, AllySchema } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Same row shape as allies; keyed lookup uses schema primary key. */
|
|
4
|
+
export type EquipmentRow = AllyRow;
|
|
5
|
+
|
|
6
|
+
export type EquipmentStore = {
|
|
7
|
+
schema: AllySchema;
|
|
8
|
+
playerSchema: AllySchema;
|
|
9
|
+
equipment: EquipmentRow[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function emptyEquipmentStore(): EquipmentStore {
|
|
13
|
+
return { schema: { fields: [] }, playerSchema: { fields: [] }, equipment: [] };
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { buildAllyByPrimaryKey, type AllyTree } from "../sdk/allyMap";
|
|
2
|
+
import type { AllyProps } from "../sdk/allyProps";
|
|
3
|
+
import { getPrimaryAllyProps } from "../sdk/allyProps";
|
|
4
|
+
import { emptyStore } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Empty defaults at build time; studio/API/runtime provider supplies real data. */
|
|
7
|
+
const store = emptyStore();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Nested by the **first schema field** (primary key value), e.g. `Ally["Nurse"]["FullBody"]`.
|
|
11
|
+
* Otherwise `{}`.
|
|
12
|
+
*/
|
|
13
|
+
export const Ally: AllyTree = buildAllyByPrimaryKey(store);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* First row as a flat object (`id` + fields). For keyed access prefer `Ally["…"]`.
|
|
17
|
+
*/
|
|
18
|
+
export const ally: AllyProps = getPrimaryAllyProps(store) ?? { id: "" };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EquipmentStore } from "../equipmentTypes";
|
|
2
|
+
import { emptyEquipmentStore } from "../equipmentTypes";
|
|
3
|
+
import { buildAllyByPrimaryKey, type AllyTree } from "../sdk/allyMap";
|
|
4
|
+
import type { AllyProps } from "../sdk/allyProps";
|
|
5
|
+
import { getPrimaryAllyProps } from "../sdk/allyProps";
|
|
6
|
+
import type { AllyStore } from "../types";
|
|
7
|
+
|
|
8
|
+
/** Empty defaults at build time; studio/API/runtime provider supplies real data. */
|
|
9
|
+
const store: EquipmentStore = emptyEquipmentStore();
|
|
10
|
+
|
|
11
|
+
const asAllyShape: AllyStore = {
|
|
12
|
+
schema: store.schema,
|
|
13
|
+
playerSchema: store.playerSchema,
|
|
14
|
+
allies: store.equipment,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Enemy rows keyed by primary-key field value.
|
|
19
|
+
*/
|
|
20
|
+
export const Enemies: AllyTree = buildAllyByPrimaryKey(asAllyShape);
|
|
21
|
+
|
|
22
|
+
/** First enemy row as flat props. */
|
|
23
|
+
export const enemyPrimary: AllyProps = getPrimaryAllyProps(asAllyShape) ?? { id: "" };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AllySchema, AllyStore } from "../types";
|
|
2
|
+
import { toAllyProps, type AllyProps } from "./allyProps";
|
|
3
|
+
|
|
4
|
+
/** `Ally["Nurse"]["FullBody"]` — outer key from the **first** schema field’s value; inner keys are all fields (+ `id`). */
|
|
5
|
+
export type AllyTree = Record<string, AllyProps>;
|
|
6
|
+
|
|
7
|
+
/** First non-empty field key in schema order — used as outer `Ally["…"]` key. */
|
|
8
|
+
export function getPrimaryKeyFieldKey(schema: AllySchema): string | null {
|
|
9
|
+
for (const f of schema.fields) {
|
|
10
|
+
const k = f.key.trim();
|
|
11
|
+
if (k) return k;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Nested lookup keyed by the **first** schema field’s value (the primary key).
|
|
18
|
+
* Duplicates overwrite (last wins). Empty primary values fall back to row `id`.
|
|
19
|
+
*/
|
|
20
|
+
export function buildAllyByPrimaryKey(store: AllyStore): AllyTree {
|
|
21
|
+
const pk = getPrimaryKeyFieldKey(store.schema);
|
|
22
|
+
if (!pk) return {};
|
|
23
|
+
|
|
24
|
+
const out: AllyTree = {};
|
|
25
|
+
for (const row of store.allies) {
|
|
26
|
+
const raw = row.values[pk];
|
|
27
|
+
const label =
|
|
28
|
+
raw != null && String(raw).trim() !== "" ? String(raw).trim() : row.id;
|
|
29
|
+
out[label] = toAllyProps(row);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when there is at least one schema field with a non-empty key (PK defined). */
|
|
35
|
+
export function isPrimaryKeyConfigured(schema: AllySchema): boolean {
|
|
36
|
+
return getPrimaryKeyFieldKey(schema) !== null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AllyRow, AllySchema, AllyStore } from "../types";
|
|
2
|
+
|
|
3
|
+
/** Flat view: `id` plus every schema value keyed by field name (use `ally["Full Body"]` when keys contain spaces). */
|
|
4
|
+
export type AllyProps = { id: string } & Record<string, string | number | boolean | null>;
|
|
5
|
+
|
|
6
|
+
function schemaKeys(schema: AllySchema): string[] {
|
|
7
|
+
const keys: string[] = [];
|
|
8
|
+
for (const f of schema.fields) {
|
|
9
|
+
const k = f.key.trim();
|
|
10
|
+
if (k) keys.push(k);
|
|
11
|
+
}
|
|
12
|
+
return keys;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Merge ally row values into one object with `id` for ergonomic access. */
|
|
16
|
+
export function toAllyProps(ally: AllyRow): AllyProps {
|
|
17
|
+
return { id: ally.id, ...ally.values };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** First ally in the store as a flat object, or `null` if there are none. */
|
|
21
|
+
export function getPrimaryAllyProps(store: AllyStore): AllyProps | null {
|
|
22
|
+
const first = store.allies[0];
|
|
23
|
+
if (!first) return null;
|
|
24
|
+
return toAllyProps(first);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Ally at index (default 0), or `null`. */
|
|
28
|
+
export function getAllyPropsAt(store: AllyStore, index: number): AllyProps | null {
|
|
29
|
+
const a = store.allies[index];
|
|
30
|
+
if (!a) return null;
|
|
31
|
+
return toAllyProps(a);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Find by id. */
|
|
35
|
+
export function getAllyPropsById(store: AllyStore, id: string): AllyProps | null {
|
|
36
|
+
const a = store.allies.find((x) => x.id === id);
|
|
37
|
+
if (!a) return null;
|
|
38
|
+
return toAllyProps(a);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** All allies as flat props (same order as `store.allies`). */
|
|
42
|
+
export function getAllAllyProps(store: AllyStore): AllyProps[] {
|
|
43
|
+
return store.allies.map(toAllyProps);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Typed getters from schema: returns an object whose keys match schema field keys and values
|
|
48
|
+
* come from `ally`. Missing keys use schema defaults (0 / "" / null).
|
|
49
|
+
*/
|
|
50
|
+
export function alignAllyToSchema(ally: AllyRow, schema: AllySchema): AllyProps {
|
|
51
|
+
const out: AllyProps = { id: ally.id };
|
|
52
|
+
for (const f of schema.fields) {
|
|
53
|
+
const k = f.key.trim();
|
|
54
|
+
if (!k) continue;
|
|
55
|
+
if (k in ally.values) {
|
|
56
|
+
out[k] = ally.values[k] as string | number | boolean | null;
|
|
57
|
+
} else {
|
|
58
|
+
out[k] = defaultForFieldType(f.type);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultForFieldType(t: "int" | "string" | "image" | "bool"): number | string | boolean | null {
|
|
65
|
+
if (t === "bool") return false;
|
|
66
|
+
if (t === "int") return 0;
|
|
67
|
+
if (t === "string") return "";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keys currently defined in the schema (trimmed, non-empty). */
|
|
72
|
+
export { schemaKeys };
|
package/lib/sdk/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
alignAllyToSchema,
|
|
3
|
+
getAllyPropsAt,
|
|
4
|
+
getAllyPropsById,
|
|
5
|
+
getAllAllyProps,
|
|
6
|
+
getPrimaryAllyProps,
|
|
7
|
+
schemaKeys,
|
|
8
|
+
toAllyProps,
|
|
9
|
+
type AllyProps,
|
|
10
|
+
} from "./allyProps";
|
|
11
|
+
export {
|
|
12
|
+
buildAllyByPrimaryKey,
|
|
13
|
+
getPrimaryKeyFieldKey,
|
|
14
|
+
isPrimaryKeyConfigured,
|
|
15
|
+
type AllyTree,
|
|
16
|
+
} from "./allyMap";
|
|
17
|
+
export { normalizeStore, parseStore } from "./parseStore";
|
package/lib/sdk/node.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { AllyStore } from "../types";
|
|
4
|
+
import { parseStore } from "./parseStore";
|
|
5
|
+
|
|
6
|
+
/** Load store JSON from disk (Node.js). Path is resolved relative to `cwd` if not absolute. */
|
|
7
|
+
export function loadStoreFromFileSync(filePath: string): AllyStore {
|
|
8
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);
|
|
9
|
+
const raw = readFileSync(abs, "utf-8");
|
|
10
|
+
return parseStore(raw);
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AllyStore } from "../types";
|
|
2
|
+
import { emptyStore } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Strip legacy fields (e.g. old `primaryKeyField`); schema is ordered fields only — first field is the PK for lookups. */
|
|
5
|
+
export function normalizeStore(store: AllyStore): AllyStore {
|
|
6
|
+
return {
|
|
7
|
+
schema: { fields: store.schema.fields },
|
|
8
|
+
playerSchema: { fields: store.playerSchema?.fields ?? [] },
|
|
9
|
+
allies: Array.isArray(store.allies) ? store.allies : [],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Parse JSON text or an already-parsed object into a normalized store. */
|
|
14
|
+
export function parseStore(input: string | unknown): AllyStore {
|
|
15
|
+
let parsed: unknown;
|
|
16
|
+
if (typeof input === "string") {
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(input) as unknown;
|
|
19
|
+
} catch {
|
|
20
|
+
return emptyStore();
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
parsed = input;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!parsed || typeof parsed !== "object") return emptyStore();
|
|
27
|
+
const o = parsed as Partial<AllyStore>;
|
|
28
|
+
if (!o.schema || !Array.isArray(o.schema.fields)) return emptyStore();
|
|
29
|
+
if (!Array.isArray(o.allies)) return normalizeStore({ ...o, allies: [] } as AllyStore);
|
|
30
|
+
return normalizeStore(o as AllyStore);
|
|
31
|
+
}
|
package/lib/storage.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BlobLane,
|
|
3
|
+
isBlobStoreConfigured,
|
|
4
|
+
migrateLegacyToLanes,
|
|
5
|
+
readAllyStoreFromBlob,
|
|
6
|
+
writeAllyStoreToBlob,
|
|
7
|
+
} from "./db/studioBlobStore";
|
|
8
|
+
import { normalizeStore } from "./sdk/parseStore";
|
|
9
|
+
import type { AllyStore } from "./types";
|
|
10
|
+
import { emptyStore } from "./types";
|
|
11
|
+
|
|
12
|
+
function requireStudioId(studioId: string | undefined, op: string): asserts studioId is string {
|
|
13
|
+
if (!studioId?.trim()) {
|
|
14
|
+
throw new Error(`${op}: studioId is required (ally data is only stored in remote object storage).`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requireRemoteStorage(op: string): void {
|
|
19
|
+
if (!isBlobStoreConfigured()) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`${op}: Remote storage is not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME — or BLOB_READ_WRITE_TOKEN — then restart the dev server or redeploy.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readStore(studioId?: string, lane: BlobLane = "draft"): Promise<AllyStore> {
|
|
27
|
+
requireStudioId(studioId, "readStore");
|
|
28
|
+
requireRemoteStorage("readStore");
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
let fromBlob = await readAllyStoreFromBlob(studioId, lane);
|
|
32
|
+
|
|
33
|
+
if (!fromBlob && lane === "draft") {
|
|
34
|
+
const migrated = await migrateLegacyToLanes(studioId);
|
|
35
|
+
if (migrated) fromBlob = await readAllyStoreFromBlob(studioId, lane);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (fromBlob && fromBlob.schema && Array.isArray(fromBlob.schema.fields)) {
|
|
39
|
+
if (!Array.isArray(fromBlob.allies)) {
|
|
40
|
+
return normalizeStore({ ...fromBlob, allies: [] });
|
|
41
|
+
}
|
|
42
|
+
return normalizeStore(fromBlob);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error("[storage] Remote read failed", err);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return emptyStore();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeStore(store: AllyStore, studioId?: string, lane: BlobLane = "draft"): Promise<void> {
|
|
53
|
+
requireStudioId(studioId, "writeStore");
|
|
54
|
+
requireRemoteStorage("writeStore");
|
|
55
|
+
const normalized = normalizeStore(store);
|
|
56
|
+
await writeAllyStoreToBlob(studioId, normalized, lane);
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
|
|
3
|
+
let cached: SupabaseClient | null | undefined;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-only client (bypasses RLS). Returns null if not configured.
|
|
7
|
+
*
|
|
8
|
+
* Prefer Supabase secret keys (`sb_secret_...`) in `SUPABASE_SECRET_KEY` (see Supabase docs:
|
|
9
|
+
* "Understanding API keys"). Legacy JWT `service_role` in `SUPABASE_SERVICE_ROLE_KEY` still works
|
|
10
|
+
* during Supabase's migration window.
|
|
11
|
+
*/
|
|
12
|
+
export function getSupabaseAdmin(): SupabaseClient | null {
|
|
13
|
+
if (cached !== undefined) return cached;
|
|
14
|
+
|
|
15
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
|
16
|
+
const key =
|
|
17
|
+
process.env.SUPABASE_SECRET_KEY?.trim() ||
|
|
18
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
|
19
|
+
if (!url || !key) {
|
|
20
|
+
cached = null;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
cached = createClient(url, key, {
|
|
25
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
26
|
+
});
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
export async function updateSession(request: NextRequest) {
|
|
5
|
+
let supabaseResponse = NextResponse.next({ request });
|
|
6
|
+
|
|
7
|
+
const supabase = createServerClient(
|
|
8
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
9
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
10
|
+
{
|
|
11
|
+
cookies: {
|
|
12
|
+
getAll() {
|
|
13
|
+
return request.cookies.getAll();
|
|
14
|
+
},
|
|
15
|
+
setAll(cookiesToSet) {
|
|
16
|
+
for (const { name, value } of cookiesToSet) {
|
|
17
|
+
request.cookies.set(name, value);
|
|
18
|
+
}
|
|
19
|
+
supabaseResponse = NextResponse.next({ request });
|
|
20
|
+
for (const { name, value, options } of cookiesToSet) {
|
|
21
|
+
supabaseResponse.cookies.set(name, value, options);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
data: { user },
|
|
30
|
+
} = await supabase.auth.getUser();
|
|
31
|
+
|
|
32
|
+
// Redirect unauthenticated users away from protected routes
|
|
33
|
+
const path = request.nextUrl.pathname;
|
|
34
|
+
const isPublic =
|
|
35
|
+
path === "/" || path === "/login" || path.startsWith("/api/") || path.startsWith("/auth/");
|
|
36
|
+
if (!user && !isPublic) {
|
|
37
|
+
const url = request.nextUrl.clone();
|
|
38
|
+
url.pathname = "/login";
|
|
39
|
+
return NextResponse.redirect(url);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Redirect authenticated users away from login
|
|
43
|
+
if (user && request.nextUrl.pathname === "/login") {
|
|
44
|
+
const url = request.nextUrl.clone();
|
|
45
|
+
url.pathname = "/";
|
|
46
|
+
return NextResponse.redirect(url);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return supabaseResponse;
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
|
|
4
|
+
export async function createClient() {
|
|
5
|
+
const cookieStore = await cookies();
|
|
6
|
+
|
|
7
|
+
return createServerClient(
|
|
8
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
9
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
10
|
+
{
|
|
11
|
+
cookies: {
|
|
12
|
+
getAll() {
|
|
13
|
+
return cookieStore.getAll();
|
|
14
|
+
},
|
|
15
|
+
setAll(cookiesToSet) {
|
|
16
|
+
try {
|
|
17
|
+
for (const { name, value, options } of cookiesToSet) {
|
|
18
|
+
cookieStore.set(name, value, options);
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// setAll is called from a Server Component where cookies can't be
|
|
22
|
+
// set — safe to ignore; the middleware will refresh the session.
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
}
|