Implement very rudimentary sync capability

This commit is contained in:
Adrian Gruntkowski 2024-11-24 12:06:39 +01:00
parent 3991491527
commit 001d7f8296
15 changed files with 473 additions and 320 deletions

View file

@ -5,7 +5,7 @@ defmodule Jenot.Account do
@primary_key {:id, :binary_id, autogenerate: true}
schema "accounts" do
timestamps(type: :utc_datetime)
timestamps(type: :utc_datetime_usec)
end
def new() do

View file

@ -9,22 +9,32 @@ defmodule Jenot.Note do
field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
field(:title, :string, default: "")
field(:content, :string)
field(:deleted_at, :utc_datetime_usec)
belongs_to(:account, Jenot.Account, type: :binary_id)
timestamps(type: :utc_datetime)
timestamps(type: :utc_datetime_usec)
end
def new(account, params) do
%__MODULE__{}
|> cast(params, [:internal_id, :type, :title, :content])
|> cast(params, [
:internal_id,
:type,
:title,
:content,
:deleted_at,
:inserted_at,
:updated_at
])
|> put_change(:id, Ecto.UUID.generate())
|> validate_required([:internal_id, :type])
|> validate_required([:internal_id, :type, :inserted_at, :updated_at])
|> put_assoc(:account, account)
end
def update(note, params) do
note
|> cast(params, [:type, :title, :content])
|> cast(params, [:type, :title, :content, :deleted_at, :updated_at])
|> validate_required([:internal_id, :type, :updated_at])
end
end

View file

@ -11,13 +11,15 @@ defmodule Jenot.Notes do
def latest_change(account) do
Note
|> where(account_id: ^account.id)
|> select([a], max(a.updated_at))
|> where([n], is_nil(n.deleted_at))
|> select([n], max(n.updated_at))
|> Repo.one()
end
def all(account, since \\ nil) do
def all(account, since \\ nil, include_deleted? \\ false) do
Note
|> where(account_id: ^account.id)
|> maybe_include_deleted(include_deleted?)
|> maybe_filter_since(since)
|> Repo.all()
end
@ -33,7 +35,11 @@ defmodule Jenot.Notes do
end
def note_by_internal_id(account, internal_id) do
note = Repo.get_by(Note, account_id: account.id, internal_id: internal_id)
note =
Note
|> where(account_id: ^account.id)
|> where(internal_id: ^internal_id)
|> Repo.one()
if note do
{:ok, note}
@ -58,7 +64,7 @@ defmodule Jenot.Notes do
def delete(account, internal_id) do
Note
|> where(account_id: ^account.id, internal_id: ^internal_id)
|> Repo.delete_all()
|> Repo.update_all(set: [deleted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()])
:ok
end
@ -79,14 +85,20 @@ defmodule Jenot.Notes do
|> Map.delete("id")
|> Map.delete(:id)
|> Map.put("internal_id", internal_id)
|> deserialize_timestamp(:deleted, :deleted_at)
|> deserialize_timestamp(:created, :inserted_at)
|> deserialize_timestamp(:updated, :updated_at)
end
end
defp serialize_note(note) do
note =
note
|> Map.take([:title, :type, :content, :inserted_at, :updated_at])
|> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at])
|> Map.put(:id, note.internal_id)
|> serialize_timestamp(:deleted_at, :deleted)
|> serialize_timestamp(:inserted_at, :created)
|> serialize_timestamp(:updated_at, :updated)
if note.type == :tasklist and not is_nil(note.content) do
%{note | content: Jason.decode!(note.content)}
@ -95,9 +107,47 @@ defmodule Jenot.Notes do
end
end
defp deserialize_timestamp(params, src_key, dst_key) do
str_src_key = to_string(src_key)
value =
case Map.get(params, str_src_key, Map.get(params, src_key)) do
nil -> nil
unix_time -> DateTime.from_unix!(unix_time, :millisecond)
end
params
|> Map.delete(src_key)
|> Map.delete(str_src_key)
|> Map.put(to_string(dst_key), value)
end
defp serialize_timestamp(data, src_key, dst_key) do
value =
case data[src_key] do
nil -> nil
dt -> DateTime.to_unix(dt, :millisecond)
end
data
|> Map.delete(src_key)
|> Map.put(dst_key, value)
end
defp maybe_include_deleted(q, true), do: q
defp maybe_include_deleted(q, false) do
where(q, [n], is_nil(n.deleted_at))
end
defp maybe_filter_since(query, nil), do: query
defp maybe_filter_since(query, datetime) do
defp maybe_filter_since(query, unix_time) do
datetime =
unix_time
|> String.to_integer()
|> DateTime.from_unix!(:millisecond)
where(query, [n], n.updated_at > ^datetime)
end
@ -106,11 +156,59 @@ defmodule Jenot.Notes do
type = Ecto.Changeset.get_field(changeset, :type) || :note
title = Ecto.Changeset.get_field(changeset, :title) || ""
content = Ecto.Changeset.get_field(changeset, :content) || ""
deleted_at = Ecto.Changeset.get_field(changeset, :deleted_at)
updated_at = Ecto.Changeset.get_field(changeset, :updated_at)
conflict_query =
from(n in Note,
update: [
set: [
type:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^updated_at,
n.updated_at,
^type,
n.type
),
title:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^updated_at,
n.updated_at,
^title,
n.title
),
content:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^updated_at,
n.updated_at,
^content,
n.content
),
deleted_at:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^updated_at,
n.updated_at,
^deleted_at,
n.deleted_at
),
updated_at:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^updated_at,
n.updated_at,
^updated_at,
n.updated_at
)
]
]
)
Repo.insert(changeset,
on_conflict: [
set: [type: type, title: title, content: content, updated_at: DateTime.utc_now()]
],
on_conflict: conflict_query,
conflict_target: conflict_target,
returning: true
)

View file

@ -8,9 +8,10 @@ defmodule Jenot.Reminder do
field(:day_of_week, :integer)
field(:repeat_period, Ecto.Enum, values: [:day, :week, :month, :year])
field(:repeat_count, :integer)
field(:deleted_at, :utc_datetime_usec)
belongs_to(:note, Jenot.Note)
timestamps(type: :utc_datetime)
timestamps(type: :utc_datetime_usec)
end
end

View file

@ -11,6 +11,6 @@ defmodule Jenot.Subscription do
belongs_to(:account, Jenot.Account)
timestamps(type: :utc_datetime)
timestamps(type: :utc_datetime_usec)
end
end

View file

@ -57,7 +57,7 @@ defmodule Jenot.Web do
notes =
account
|> Notes.all(conn.params["since"])
|> Notes.all(conn.params["since"], conn.params["deleted"] == "true")
|> Enum.map(&Notes.serialize/1)
send_resp(conn, 200, Jason.encode!(notes))
@ -108,7 +108,7 @@ defmodule Jenot.Web do
:ok = Notes.delete(account, internal_id)
send_resp(conn, 204, "")
send_resp(conn, 200, Jason.encode!(%{deleted: true}))
end
get "/api/push/public-key" do

View file

@ -14,6 +14,7 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
add :type, :text, null: false
add :title, :text, null: false, default: ""
add :content, :text, null: false, default: ""
add :deleted_at, :datetime_usec
add :account_id, references(:accounts, on_delete: :delete_all), null: false
@ -49,6 +50,8 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
add :repeat_period, :text
add :repeat_count, :integer
add :deleted_at, :datetime_usec
add :note_id, references(:note, on_delete: :delete_all), null: false
timestamps(type: :datetime_usec)

View file

@ -1,42 +0,0 @@
const putInCache = async (request, response) => {
const cache = await caches.open("v1");
await cache.put(request, response);
};
const cacheFirst = async ({ request, fallbackUrl }) => {
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
try {
const responseFromNetwork = await fetch(request);
// Cloning is needed because a response can only be consumed once.
putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
return new Response("Network error happened", {
status: 408,
headers: { "Content-Type": "text/plain" },
});
}
};
self.addEventListener("fetch", (event) => {
const requestUrl = URL.parse(event.request.url);
// We don't cache API requests
if (!requestUrl.pathname.startsWith("/api")) {
event.respondWith(
cacheFirst({
request: event.request,
fallbackUrl: "/index.html",
}),
);
}
});

View file

@ -1,143 +0,0 @@
export class DBNoteStore extends EventTarget {
constructor(dbName, storeName) {
super();
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async all() {
const that = this;
return this.#connect().then(
(db) =>
new Promise((resolve, reject) => {
db
.transaction([that.storeName], "readonly")
.objectStore(that.storeName)
.getAll().onsuccess = (data) => resolve(data.target.result);
}),
);
}
async get(id) {
const that = this;
let result;
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readonly")
.objectStore(that.storeName)
.get(id).onsuccess = (data) => resolve(data.target.result)),
),
);
}
async add(note) {
const that = this;
const now = Date.now();
const entry = {
id: "id_" + now,
type: note.type,
content: note.content,
created: now,
updated: now,
};
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.add(entry).onsuccess = () => resolve(null)),
),
);
}
async reset() {
const that = this;
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.clear().onsuccess = () => resolve(null)),
),
);
}
async remove({ id }) {
const that = this;
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.delete(id).onsuccess = () => resolve(null)),
),
);
}
async update(note) {
const that = this;
note.updated = Date.now();
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.put(note).onsuccess = () => resolve(null)),
),
);
}
saveStorage() {
this.dispatchEvent(new CustomEvent("save"));
}
async #connect() {
if (!this.db) {
this.db = await this.#dbConnect();
}
return this.db;
}
#dbConnect() {
const that = this;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onsuccess = (e) => {
resolve(e.target.result);
};
request.onerror = (e) => {
console.error(`indexedDB error: ${e.target.errorCode}`);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore(that.storeName, {
keyPath: "id",
});
};
});
}
}

View file

@ -1,8 +1,7 @@
import "./service-worker.js";
import "./service-worker-init.js";
import { renderText } from "./dom.js";
import { NoteStore } from "./store.js";
import { DBNoteStore } from "./db-store.js";
import { WebNoteStore } from "./web-store.js";
import { LocalNoteStore } from "./local-store.js";
import { SyncedNoteStore } from "./synced-store.js";
import {
authorizeNotifications,
notificationsEnabled,
@ -29,8 +28,16 @@ notificationsTestButton.addEventListener("click", () => {
const urlParams = new URLSearchParams(window.location.search);
const Notes = urlParams.has("localStorage")
? new NoteStore("jenot-app")
: new WebNoteStore("jenot-app", "notes");
? new LocalNoteStore("jenot-app")
: new SyncedNoteStore("jenot-app", "notes", "/");
const sync = async () => {
await Notes.sync();
Notes.saveStorage();
};
sync();
setInterval(sync, 5000);
const newNote = document.querySelector("#new-note");
const editNote = document.querySelector("#edit-note");
@ -94,7 +101,6 @@ async function render() {
newNote.classList.add("hidden");
editNote.classList.remove("hidden");
const note = await Notes.get(container.id);
console.log("edited note", note);
editNote.load(note);
});
});

View file

@ -1,4 +1,4 @@
export class NoteStore extends EventTarget {
export class LocalNoteStore extends EventTarget {
localStorageKey;
notes = [];
@ -44,10 +44,6 @@ export class NoteStore extends EventTarget {
});
}
reset() {
this.notes = [];
}
remove({ id }) {
this.notes = this.notes.filter((note) => note.id !== id);
}
@ -57,6 +53,8 @@ export class NoteStore extends EventTarget {
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
}
sync() {}
saveStorage() {
window.localStorage.setItem(
this.localStorageKey + "_notes",

View file

@ -0,0 +1,24 @@
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register(
"/js/service-worker.js",
{
type: "module",
scope: "/",
},
);
if (registration.installing) {
console.log("Service worker installing");
} else if (registration.waiting) {
console.log("Service worker installed");
} else if (registration.active) {
console.log("Service worker active");
}
} catch (error) {
console.error(`Registration failed with ${error}`, error);
}
}
};
registerServiceWorker();

View file

@ -1,23 +1,46 @@
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register(
"/js/caching-worker.js",
{
scope: "/",
},
);
if (registration.installing) {
console.log("Service worker installing");
} else if (registration.waiting) {
console.log("Service worker installed");
} else if (registration.active) {
console.log("Service worker active");
}
} catch (error) {
console.error(`Registration failed with ${error}`);
// caching
const putInCache = async (request, response) => {
const cache = await caches.open("v1");
await cache.put(request, response);
};
const cacheFirst = async ({ request, fallbackUrl }) => {
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
try {
const responseFromNetwork = await fetch(request);
// Cloning is needed because a response can only be consumed once.
putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
return new Response("Network error happened", {
status: 408,
headers: { "Content-Type": "text/plain" },
});
}
};
registerServiceWorker();
self.addEventListener("fetch", (event) => {
// We don't cache API requests
if (!event.request.url?.pathname) {
return true;
}
if (!event.request.url.pathname.startsWith("/api")) {
event.respondWith(
cacheFirst({
request: event.request,
fallbackUrl: "/index.html",
}),
);
}
});

View file

@ -0,0 +1,261 @@
class WebNoteStore {
constructor(endpoint) {
this.endpoint = endpoint || "/";
}
async all(lastSync, includeDeleted) {
const params = new URLSearchParams();
if (lastSync > 0) {
params.append("since", lastSync);
}
if (includeDeleted) {
params.append("deleted", "true");
}
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return this.#request(`${this.endpoint}api/notes${suffix}`, {}, () => []);
}
async get(id) {
return this.#request(`${this.endpoint}api/notes/${id}`, {}, () => null);
}
async add(note) {
return this.#request(
`${this.endpoint}api/notes`,
{
method: "POST",
body: JSON.stringify(note),
},
() => null,
);
}
async update(note) {
return this.#request(
`${this.endpoint}api/notes/${note.id}`,
{
method: "PUT",
body: JSON.stringify(note),
},
() => null,
);
}
async #request(url, opts, errorCallback) {
opts.headers = {
...(opts.headers || {}),
"Content-Type": "application/json",
};
try {
const response = await fetch(url, opts);
if (!response.ok) {
console.error("Request failed", response);
return errorCallback(response);
}
return response.json();
} catch (error) {
console.error("Request error", error);
return errorCallback(error);
}
}
}
export class SyncedNoteStore extends EventTarget {
constructor(dbName, storeName, endpoint) {
super();
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
this.webStore = endpoint && new WebNoteStore(endpoint);
}
async all(since, includeDeleted) {
const that = this;
return this.#connect().then(
(db) =>
new Promise((resolve, reject) => {
db
.transaction([that.storeName], "readonly")
.objectStore(that.storeName)
.getAll().onsuccess = (data) => {
const results = data.target.result.filter(
(n) => (includeDeleted || !n.deleted) && n.id !== "meta",
);
if (since > 0) {
return resolve(results.filter((n) => n.updated > since));
}
return resolve(results);
};
}),
);
}
async get(id) {
const that = this;
let result;
return this.#connect().then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readonly")
.objectStore(that.storeName)
.get(id).onsuccess = (data) => resolve(data.target.result)),
),
);
}
async getMeta() {
return this.get("meta").then((meta) => meta || { lastSync: null });
}
async setMeta(meta) {
meta.id = "meta";
this.update(meta, true);
}
async add(note) {
const that = this;
const now = Date.now();
const entry = {
id: "id_" + now,
type: note.type,
content: note.content,
created: now,
updated: now,
deleted: null,
};
return this.#connect()
.then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.add(entry).onsuccess = () => resolve(null)),
),
)
.then(() => {
(async () => this.webStore?.add(entry))();
return null;
});
}
async remove(note) {
const that = this;
note.deleted = Date.now();
return this.update(note);
}
async update(note, skipNetwork) {
const that = this;
if (!skipNetwork) {
note.updated = Date.now();
}
return this.#connect()
.then(
(db) =>
new Promise(
(resolve, reject) =>
(db
.transaction([that.storeName], "readwrite")
.objectStore(that.storeName)
.put(note).onsuccess = () => resolve(null)),
),
)
.then(() => {
(async () => (skipNetwork ? null : this.webStore?.update(note)))();
return null;
});
}
async sync() {
const that = this;
const meta = await this.getMeta();
const lastSync = meta?.lastSync;
this.all(lastSync, true)
.then((notes) => {
notes.forEach(async (n) => await that.webStore.add(n));
})
.then(() => that.webStore.all(lastSync, true))
.then((notes) => {
notes.forEach(async (n) => await that.update(n, true));
})
.then(() => {
meta.lastSync = Date.now();
that.setMeta(meta);
});
}
saveStorage() {
this.dispatchEvent(new CustomEvent("save"));
}
async #connect() {
if (!this.db) {
this.db = await this.#dbConnect();
}
return this.db;
}
#dbConnect() {
const that = this;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onsuccess = (e) => {
resolve(e.target.result);
};
request.onerror = (e) => {
console.error(`indexedDB error: ${e.target.errorCode}`);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore(that.storeName, {
keyPath: "id",
});
};
});
}
#request(url, opts, emptyValue) {
new Promise((resolve) => {
return resolve(this.#runRequest(url, opts, () => emptyValue));
});
}
async #runRequest(url, opts, errorCallback) {
opts.headers = {
...(opts.headers || {}),
"Content-Type": "application/json",
};
try {
const response = await fetch(url, opts);
if (!response.ok) {
console.error("Request failed", response);
return errorCallback(response);
}
return response.json();
} catch (error) {
console.error("Request error", error);
return errorCallback(error);
}
}
}

View file

@ -1,86 +0,0 @@
export class WebNoteStore extends EventTarget {
constructor(dbName, storeName, endpoint) {
super();
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
this.endpoint = endpoint || "/";
}
async all() {
return this.#request(`${this.endpoint}api/notes`, {}, () => []);
}
async get(id) {
console.log("get", id);
return this.#request(`${this.endpoint}api/notes/${id}`, {}, () => null);
}
async add(note) {
const now = Date.now();
const entry = {
id: "id_" + now,
type: note.type,
content: note.content,
};
return this.#request(
`${this.endpoint}api/notes`,
{
method: "POST",
body: JSON.stringify(entry),
},
() => null,
);
}
async reset() {
// NOOP for now
return null;
}
async remove({ id }) {
return this.#request(
`${this.endpoint}api/notes/${id}`,
{
method: "DELETE",
},
() => null,
);
}
async update(note) {
return this.#request(
`${this.endpoint}api/notes/${note.id}`,
{
method: "PUT",
body: JSON.stringify(note),
},
() => null,
);
}
saveStorage() {
this.dispatchEvent(new CustomEvent("save"));
}
async #request(url, opts, errorCallback) {
opts.headers = {
...(opts.headers || {}),
"Content-Type": "application/json",
};
try {
const response = await fetch(url, opts);
if (!response.ok) {
console.error("Request failed", response);
return errorCallback(response);
}
return response.json();
} catch (error) {
console.error("Request error", error);
return errorCallback(error);
}
}
}