initial commit

This commit is contained in:
Vuk Savić
2026-03-16 21:38:49 +01:00
commit d1eeccbefc
21 changed files with 3178 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Guildlib.Runtime
{
// =========================================================================
// ATTRIBUTES
// Apply these to your DataEntry subclasses to configure Guildlib behaviour.
// =========================================================================
/// <summary>
/// REQUIRED on every concrete DataEntry subclass.
/// Declares which shard database this type belongs to.
///
/// The shard name is free-form — use any string you want.
/// The server will auto-create a new .sqlite file for any new shard name
/// the first time an entry of that type is pushed.
///
/// Usage:
/// [ShardTarget("characters")]
/// [ShardTarget("ui")]
/// [ShardTarget("levels")]
///
/// Shard names are lowercase, no spaces. Use underscores if needed:
/// [ShardTarget("vfx_particles")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ShardTargetAttribute : Attribute
{
public string ShardName { get; }
public ShardTargetAttribute(string shardName) => ShardName = shardName;
}
/// <summary>
/// OPTIONAL — gives the type a human-readable label for the web editor.
/// If omitted, the class name is used.
///
/// Usage:
/// [GuildLabel("Character Stats")]
/// [GuildLabel("UI Button")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildLabelAttribute : Attribute
{
public string Label { get; }
public GuildLabelAttribute(string label) => Label = label;
}
/// <summary>
/// OPTIONAL — a short description shown in the web editor under the type name.
///
/// Usage:
/// [GuildDescription("Defines stats for all enemy and player characters.")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildDescriptionAttribute : Attribute
{
public string Description { get; }
public GuildDescriptionAttribute(string desc) => Description = desc;
}
/// <summary>
/// OPTIONAL — declares which platforms this entry type is relevant for.
/// This is a DEFAULT for new entries of this type. Individual entries can
/// override their platform tags at runtime in the web editor.
///
/// Platforms are free-form strings — define your own set.
/// Common examples: "pc", "ps5", "xbox", "switch", "mobile", "all"
///
/// Usage:
/// [GuildPlatforms("all")]
/// [GuildPlatforms("pc", "ps5", "xbox")]
/// [GuildPlatforms("switch", "mobile")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildPlatformsAttribute : Attribute
{
public string[] Platforms { get; }
public GuildPlatformsAttribute(params string[] platforms) => Platforms = platforms;
}
/// <summary>
/// OPTIONAL — declares which build configurations (tags) this entry type
/// belongs to by default.
///
/// Tags are free-form — define your own taxonomy.
/// Examples: "debug", "release", "demo", "dlc1", "alpha"
///
/// Usage:
/// [GuildTags("release", "demo")]
/// [GuildTags("debug")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildTagsAttribute : Attribute
{
public string[] Tags { get; }
public GuildTagsAttribute(params string[] tags) => Tags = tags;
}
/// <summary>
/// OPTIONAL on a field or property — exclude it from schema export and sync.
/// Use for Unity-internal or editor-only fields that should not hit the server.
///
/// Usage:
/// [GuildExclude]
/// public Texture2D previewTexture; // Unity asset ref, not serialisable to DB
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class GuildExcludeAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a field — marks it as the display name for this entry
/// in the web editor's entry list. Without this, entryId is shown.
///
/// Usage:
/// [GuildDisplayName]
/// public string characterName;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildDisplayNameAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a field — marks it as a reference to another shard entry.
/// The web editor will show a picker instead of a plain text field.
///
/// Usage:
/// [GuildRef("characters")]
/// public string ownerCharacterId;
///
/// [GuildRef("audio")]
/// public string deathSoundId;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildRefAttribute : Attribute
{
public string TargetShard { get; }
public GuildRefAttribute(string targetShard) => TargetShard = targetShard;
}
/// <summary>
/// OPTIONAL on a field — hints the web editor to render a multiline
/// textarea instead of a single-line input.
///
/// Usage:
/// [GuildMultiline]
/// public string description;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildMultilineAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a string field — constrains the value to one of the
/// provided options. The web editor renders a dropdown.
///
/// Usage:
/// [GuildEnum("None", "Rare", "Epic", "Legendary")]
/// public string rarity;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildEnumAttribute : Attribute
{
public string[] Options { get; }
public GuildEnumAttribute(params string[] options) => Options = options;
}
// =========================================================================
// PLATFORM / TAG REGISTRY
// Define your project's platform and tag vocabulary here.
// These are used for validation and for populating dropdowns in the
// web editor. Add or remove entries freely — they're just strings.
// =========================================================================
/// <summary>
/// Central registry for all platform identifiers used in this project.
/// Edit this list to match your shipping targets.
/// </summary>
public static class GuildPlatformRegistry
{
/// <summary>
/// All valid platform names for this project.
/// Add your platforms here — these appear as checkboxes in the web editor.
/// </summary>
public static readonly string[] All = new[]
{
"pc",
"ps5",
"ps4",
"xbox_series",
"xbox_one",
"switch",
"mobile_ios",
"mobile_android",
"vr",
};
/// <summary>
/// Convenience constant — use as a shorthand for "ships on every platform".
/// </summary>
public const string AllPlatforms = "all";
}
/// <summary>
/// Central registry for all tag identifiers used in this project.
/// Tags can represent build configs, DLC groups, content regions,
/// quality tiers, or any other grouping you need.
/// </summary>
public static class GuildTagRegistry
{
/// <summary>
/// All valid tag names for this project.
/// Add your tags here — these appear as checkboxes in the web editor.
/// </summary>
public static readonly string[] All = new[]
{
// Build types
"release",
"debug",
"demo",
// DLC / content waves
"base_game",
"dlc_1",
"dlc_2",
// Quality / LOD tiers
"high",
"medium",
"low",
// Region
"global",
"region_jp",
"region_eu",
"region_na",
};
}
// =========================================================================
// DataEntry BASE CLASS
// Every entry stored in Guildlib inherits from this.
// =========================================================================
/// <summary>
/// Base class for all Guildlib-managed data entries.
///
/// To create a new data type:
///
/// 1. Subclass DataEntry
/// 2. Add [ShardTarget("your_shard_name")] ← required
/// 3. Optionally add [GuildLabel], [GuildDescription], [GuildPlatforms], [GuildTags]
/// 4. Add your public fields
/// 5. Open Window → Guildlib → Sync Panel and click Upload Schema
///
/// Example:
///
/// [ShardTarget("characters")]
/// [GuildLabel("Character Stats")]
/// [GuildPlatforms("all")]
/// [GuildTags("base_game", "release")]
/// [CreateAssetMenu(menuName = "Guildlib/Character Stats")]
/// public class CharacterStats : DataEntry
/// {
/// [GuildDisplayName]
/// public string characterName;
/// public float maxHealth;
/// public float moveSpeed;
///
/// [GuildEnum("Warrior", "Mage", "Rogue")]
/// public string characterClass;
/// }
/// </summary>
[Serializable]
public abstract class DataEntry : ScriptableObject
{
// ── Guildlib-managed fields (not shown in web editor forms) ──────────
[HideInInspector]
public string entryId = Guid.NewGuid().ToString("N");
[HideInInspector]
public long rowVersion = 0;
/// <summary>
/// ID of a parent entry (same shard). Set this to link a child entry
/// to its parent, e.g. a specific SwordData instance linked to a
/// WeaponData template.
/// </summary>
[HideInInspector]
public string parentId = null;
[HideInInspector]
public string typeName;
[HideInInspector]
public string shardName;
/// <summary>
/// Platform tags for this specific entry instance.
/// Defaults to the class-level [GuildPlatforms] attribute value,
/// but can be overridden per-entry in the web editor.
/// </summary>
[HideInInspector]
public List<string> platforms = new();
/// <summary>
/// Content/build tags for this specific entry instance.
/// Defaults to the class-level [GuildTags] attribute value,
/// but can be overridden per-entry in the web editor.
/// </summary>
[HideInInspector]
public List<string> tags = new();
protected virtual void OnEnable()
{
typeName = GetType().FullName;
var shard = GetType().GetCustomAttributes(typeof(ShardTargetAttribute), false);
shardName = shard.Length > 0
? ((ShardTargetAttribute)shard[0]).ShardName
: "default";
// Apply class-level platform defaults if none set yet
if (platforms.Count == 0)
{
var pa = (GuildPlatformsAttribute[])GetType()
.GetCustomAttributes(typeof(GuildPlatformsAttribute), false);
if (pa.Length > 0) platforms.AddRange(pa[0].Platforms);
else platforms.Add(GuildPlatformRegistry.AllPlatforms);
}
// Apply class-level tag defaults if none set yet
if (tags.Count == 0)
{
var ta = (GuildTagsAttribute[])GetType()
.GetCustomAttributes(typeof(GuildTagsAttribute), false);
if (ta.Length > 0) tags.AddRange(ta[0].Tags);
}
}
}
}

View File

@@ -0,0 +1,47 @@
using UnityEngine;
namespace Guildlib.Runtime
{
/// <summary>
/// Project-level Guildlib configuration.
///
/// HOW TO CREATE:
/// Assets → Create → Guildlib → Config
/// Name it exactly "GuildlibConfig" and place it in any Resources/ folder.
///
/// HOW TO CONFIGURE:
/// - Server URL: your Bun server address (local or production)
/// - Project ID: any unique string identifying your game project
/// - API Key: must match GUILDLIB_API_KEY env var on the server
/// Leave blank during local development (server auth disabled)
/// - Shard list: comma-separated list of shards to sync, or leave empty
/// to sync ALL shards the server knows about
/// </summary>
[CreateAssetMenu(menuName = "Guildlib/Config", fileName = "GuildlibConfig")]
public class GuildlibConfig : ScriptableObject
{
[Header("Server connection")]
public string serverUrl = "http://localhost:3000";
public string projectId = "";
[Tooltip("Must match GUILDLIB_API_KEY on the server. Leave blank for local dev.")]
public string apiKey = "";
[Header("Local storage")]
[Tooltip("Folder name inside Application.persistentDataPath where .sqlite shards are saved.")]
public string shardFolder = "GuildlibShards";
[Header("Sync targets")]
[Tooltip("Leave empty to sync all shards. Or list specific shard names to limit syncing.")]
public string[] shardsToSync = new string[0];
/// <summary>
/// Returns the list of shards to sync. If empty, returns null
/// (meaning: ask the server for all available shards).
/// </summary>
public string[] GetSyncTargets() =>
shardsToSync != null && shardsToSync.Length > 0 ? shardsToSync : null;
public static GuildlibConfig Load() =>
Resources.Load<GuildlibConfig>("GuildlibConfig");
}
}

View File

@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEngine;
using Newtonsoft.Json;
namespace Guildlib.Runtime
{
// =========================================================================
// Schema wire types — serialised to JSON and sent to the server
// =========================================================================
[Serializable]
public class FieldSchema
{
public string name;
public string type; // "string" | "int" | "float" | "bool" | "string[]" …
public bool isArray;
public bool isInherited; // true = defined on a parent class
public bool isDisplayName; // true = [GuildDisplayName] present
public bool isMultiline; // true = [GuildMultiline] present
public string refShard; // non-null = [GuildRef("shard")] present
public string[] enumOptions; // non-null = [GuildEnum(...)] present
public string defaultValue;
}
[Serializable]
public class TypeSchema
{
public string typeName; // fully-qualified C# type name
public string displayName; // from [GuildLabel] or class name
public string description; // from [GuildDescription] or ""
public string shard; // from [ShardTarget]
public string parentType; // null if top-level DataEntry subclass
public List<string> childTypes = new();
public List<string> defaultPlatforms = new(); // from [GuildPlatforms]
public List<string> defaultTags = new(); // from [GuildTags]
public List<FieldSchema> fields = new();
}
[Serializable]
public class PlatformConfig
{
public List<string> platforms = new();
public List<string> tags = new();
}
[Serializable]
public class SchemaManifest
{
public string version = "2";
public long exportedAt;
public string unityProjectId;
public List<string> shards = new(); // distinct shard names
public PlatformConfig platformConfig = new(); // all valid platforms + tags
public List<TypeSchema> types = new();
}
// =========================================================================
// Exporter
// =========================================================================
public static class SchemaExporter
{
public static string Export(string projectId = "")
{
var manifest = new SchemaManifest
{
exportedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
unityProjectId = projectId,
platformConfig = new PlatformConfig
{
platforms = GuildPlatformRegistry.All.ToList(),
tags = GuildTagRegistry.All.ToList(),
}
};
// Discover all concrete DataEntry subclasses in all loaded assemblies
var baseType = typeof(DataEntry);
var entryTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
.Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
.ToList();
var shardSet = new HashSet<string>();
var typeMap = new Dictionary<string, TypeSchema>();
foreach (var t in entryTypes)
{
var shardAttr = t.GetCustomAttribute<ShardTargetAttribute>(false);
var shard = shardAttr?.ShardName ?? "default";
shardSet.Add(shard);
// Display name
var labelAttr = t.GetCustomAttribute<GuildLabelAttribute>(false);
var displayName = labelAttr?.Label ?? t.Name;
// Description
var descAttr = t.GetCustomAttribute<GuildDescriptionAttribute>(false);
var desc = descAttr?.Description ?? "";
// Default platforms
var platformAttr = t.GetCustomAttribute<GuildPlatformsAttribute>(false);
var defaultPlats = platformAttr != null
? platformAttr.Platforms.ToList()
: new List<string> { GuildPlatformRegistry.AllPlatforms };
// Default tags
var tagsAttr = t.GetCustomAttribute<GuildTagsAttribute>(false);
var defaultTags = tagsAttr?.Tags.ToList() ?? new List<string>();
// Parent type (walk up skipping non-DataEntry classes)
string parentTypeName = null;
var parent = t.BaseType;
while (parent != null && parent != baseType &&
parent != typeof(ScriptableObject) &&
parent != typeof(UnityEngine.Object))
{
if (entryTypes.Contains(parent)) { parentTypeName = parent.FullName; break; }
parent = parent.BaseType;
}
var schema = new TypeSchema
{
typeName = t.FullName,
displayName = displayName,
description = desc,
shard = shard,
parentType = parentTypeName,
defaultPlatforms = defaultPlats,
defaultTags = defaultTags,
fields = BuildFields(t, entryTypes),
};
typeMap[t.FullName] = schema;
manifest.types.Add(schema);
}
// Second pass — populate childTypes
foreach (var ts in manifest.types)
if (ts.parentType != null && typeMap.TryGetValue(ts.parentType, out var p))
p.childTypes.Add(ts.typeName);
manifest.shards = shardSet.OrderBy(s => s).ToList();
return JsonConvert.SerializeObject(manifest, Formatting.Indented);
}
// ── Field reflection ─────────────────────────────────────────────────
static List<FieldSchema> BuildFields(Type t, List<Type> allEntryTypes)
{
var result = new List<FieldSchema>();
var baseType = typeof(DataEntry);
var seen = new HashSet<string>();
// Walk up chain so parent fields appear first
var chain = new List<Type>();
var cur = t;
while (cur != null && cur != baseType && cur != typeof(ScriptableObject))
{
chain.Add(cur); cur = cur.BaseType;
}
chain.Reverse();
foreach (var type in chain)
{
bool inherited = type != t;
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.DeclaredOnly))
{
if (f.GetCustomAttribute<GuildExcludeAttribute>() != null) continue;
if (f.GetCustomAttribute<HideInInspector>() != null) continue;
if (seen.Contains(f.Name)) continue;
seen.Add(f.Name);
var refAttr = f.GetCustomAttribute<GuildRefAttribute>();
var enumAttr = f.GetCustomAttribute<GuildEnumAttribute>();
result.Add(new FieldSchema
{
name = f.Name,
type = MapType(f.FieldType),
isArray = f.FieldType.IsArray,
isInherited = inherited,
isDisplayName = f.GetCustomAttribute<GuildDisplayNameAttribute>() != null,
isMultiline = f.GetCustomAttribute<GuildMultilineAttribute>() != null,
refShard = refAttr?.TargetShard,
enumOptions = enumAttr?.Options,
defaultValue = GetDefault(f.FieldType),
});
}
}
return result;
}
static string MapType(Type t)
{
if (t == typeof(string)) return "string";
if (t == typeof(int)) return "int";
if (t == typeof(long)) return "long";
if (t == typeof(float)) return "float";
if (t == typeof(double)) return "double";
if (t == typeof(bool)) return "bool";
if (t == typeof(string[])) return "string[]";
if (t == typeof(int[])) return "int[]";
if (t == typeof(float[])) return "float[]";
return "string";
}
static string GetDefault(Type t)
{
if (t == typeof(bool)) return "false";
if (t == typeof(float) || t == typeof(double) ||
t == typeof(int) || t == typeof(long)) return "0";
return "";
}
}
}

View File

@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using Newtonsoft.Json;
namespace Guildlib.Runtime
{
// ── Wire types ────────────────────────────────────────────────────────────
public class ManifestRow
{
public string id;
public long version;
public string checksum;
}
public class ShardManifest
{
public string shard;
public List<ManifestRow> rows;
}
public class PullRequest
{
public string shard;
public List<string> ids;
// Optional platform filter — only pull rows tagged for these platforms.
// The server will include rows tagged "all" regardless of this list.
// Set to null to pull everything.
public List<string> filterPlatforms;
// Optional tag filter — only pull rows that have at least one of these tags.
// Set to null to pull everything.
public List<string> filterTags;
}
public class PullResponse
{
public string shard;
public List<Dictionary<string,object>> rows;
}
public class PushPayload
{
public string shard;
public string typeName;
public Dictionary<string,object> data;
}
public class SyncResult
{
public string shard;
public int added;
public int updated;
public int skipped;
}
// ── ShardClient ───────────────────────────────────────────────────────────
public class ShardClient
{
readonly GuildlibConfig config;
readonly HttpClient http;
readonly string shardName;
readonly string dbPath;
// Local manifest: entryId -> (version, checksum)
Dictionary<string,(long version, string checksum)> localManifest = new();
public ShardClient(GuildlibConfig cfg, string shard)
{
config = cfg;
shardName = shard;
http = new HttpClient();
if (!string.IsNullOrEmpty(cfg.apiKey))
http.DefaultRequestHeaders.Add("X-Api-Key", cfg.apiKey);
var dir = Path.Combine(Application.persistentDataPath, cfg.shardFolder);
Directory.CreateDirectory(dir);
dbPath = Path.Combine(dir, $"{shard}.sqlite");
LoadLocalManifest();
}
// ── Pull ─────────────────────────────────────────────────────────────
/// <summary>
/// Pull all changed rows from the server for this shard.
///
/// platformFilter: if non-null, only pull rows whose platforms include
/// at least one of the given values, or "all".
/// Example: new List<string>{"pc","ps5"}
///
/// tagFilter: if non-null, only pull rows with at least one matching tag.
/// Example: new List<string>{"release","base_game"}
///
/// Both filters are applied server-side so no extra data crosses the wire.
/// </summary>
public async Task<SyncResult> PullAsync(
List<string> platformFilter = null,
List<string> tagFilter = null)
{
// 1 — Fetch server manifest (lightweight: id/version/checksum only)
var manifestJson = await http.GetStringAsync($"{config.serverUrl}/manifest/{shardName}");
var serverManifest = JsonConvert.DeserializeObject<ShardManifest>(manifestJson);
// 2 — Diff against local manifest
var needIds = new List<string>();
foreach (var row in serverManifest.rows)
{
if (!localManifest.TryGetValue(row.id, out var local) ||
local.version < row.version ||
local.checksum != row.checksum)
needIds.Add(row.id);
}
var result = new SyncResult
{
shard = shardName,
skipped = serverManifest.rows.Count - needIds.Count
};
if (needIds.Count == 0) return result;
// 3 — Request only the delta rows, with optional server-side filtering
var pullReq = new PullRequest
{
shard = shardName,
ids = needIds,
filterPlatforms = platformFilter,
filterTags = tagFilter,
};
var reqJson = JsonConvert.SerializeObject(pullReq);
var response = await http.PostAsync(
$"{config.serverUrl}/pull",
new StringContent(reqJson, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
var pullJson = await response.Content.ReadAsStringAsync();
var pullResp = JsonConvert.DeserializeObject<PullResponse>(pullJson);
// 4 — Upsert into local SQLite shard
int added = 0, updated = 0;
using var db = new LocalShardDb(dbPath);
db.EnsureTable(shardName);
foreach (var row in pullResp.rows)
{
bool isNew = !localManifest.ContainsKey(row["id"].ToString());
db.Upsert(shardName, row);
if (isNew) added++; else updated++;
}
// 5 — Persist updated manifest
foreach (var mr in serverManifest.rows)
localManifest[mr.id] = (mr.version, mr.checksum);
SaveLocalManifest();
result.added = added;
result.updated = updated;
return result;
}
// ── Push ─────────────────────────────────────────────────────────────
public async Task PushAsync(DataEntry entry)
{
var data = EntryToDict(entry);
var payload = new PushPayload
{
shard = shardName,
typeName = entry.typeName,
data = data,
};
var json = JsonConvert.SerializeObject(payload);
var response = await http.PostAsync(
$"{config.serverUrl}/push",
new StringContent(json, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
}
// ── Local manifest ────────────────────────────────────────────────────
void LoadLocalManifest()
{
var path = dbPath + ".manifest.json";
if (!File.Exists(path)) return;
try
{
var raw = File.ReadAllText(path);
localManifest = JsonConvert.DeserializeObject<
Dictionary<string,(long, string)>>(raw) ?? new();
}
catch { localManifest = new(); }
}
void SaveLocalManifest()
{
File.WriteAllText(dbPath + ".manifest.json",
JsonConvert.SerializeObject(localManifest, Formatting.Indented));
}
// ── Helpers ───────────────────────────────────────────────────────────
static Dictionary<string,object> EntryToDict(DataEntry entry)
{
var dict = new Dictionary<string,object>();
foreach (var f in entry.GetType().GetFields(
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.Instance))
{
dict[f.Name] = f.GetValue(entry);
}
return dict;
}
}
// ── LocalShardDb stub ─────────────────────────────────────────────────────
// Replace the method bodies with your chosen Unity SQLite package.
// Recommended: https://github.com/gkoreman/SQLite4Unity3d
// or sqlite-net-pcl via NuGet
public class LocalShardDb : IDisposable
{
readonly string path;
public LocalShardDb(string dbPath) { path = dbPath; }
public void EnsureTable(string tableName)
{
// CREATE TABLE IF NOT EXISTS <tableName> (
// id TEXT PRIMARY KEY,
// type_name TEXT,
// parent_id TEXT,
// platforms TEXT, -- JSON array
// tags TEXT, -- JSON array
// row_version INTEGER,
// data_json TEXT,
// updated_at INTEGER
// )
}
public void Upsert(string tableName, Dictionary<string,object> row)
{
// INSERT OR REPLACE INTO <tableName> VALUES (...)
}
public void Dispose() { }
}
}