الانتقال إلى المحتوى الرئيسي

Yjs

تُبقي الحزمة @dineug/erd-editor-yjs المخططات متزامنة عبر مستند Yjs. وهي حزمة مستقلة، صدر أول إصدار منها 0.1.0 بجانب @dineug/erd-editor 5.1.0.
تربط الدالة bindErdEditor عنصر <erd-editor>، وتربط الدالة bindPeerStore مخزن نظير بدون واجهة من @dineug/erd-editor/peer.js، بـ Y.Doc.
أي مزوّد ينقل Y.Doc ينقل المخطط معه: خادم websocket أو WebRTC أو IndexedDB أو مزوّد من صنعك. يبقى المزوّد وY.Doc ملكًا لك: فالربط لا يتصل بهما ولا يقطع اتصالهما ولا يتلفهما أبدًا.

تنتقل التعديلات على شكل دفعات الإجراءات الخاصة بالمحرر، ويحتفظ بها الربط كما أرسلها المخزن.
لذلك تحسم مسجّلات التعاون في المحرر التعديلات التي تتم في اللحظة نفسها كما تحسمها في جلسة موصولة عبر getSharedStore، مع الاستثناءات نفسها، مثل تغيير اللون، راجع التعديلات المتزامنة.
تنتقل المؤشرات والتركيز والتحديدات ومربعات السحب عبر Awareness الخاص بالمزوّد، راجع الحضور.
مقارنةً بوصل مخزن مشترك بوسيلة نقل بنفسك، يضيف الربط ما يلي:

يحتاج الربط إلى الدوال الخمس التي أضافها الإصدار 5.1.0 إلى المخزن المشترك ومخزن النظير — getLWW وmergeLWW وgetVersion وforgetPeer وreseed — ويجري بنفسه كل استدعاء يلزمه منها.

التثبيت​

npm install @dineug/erd-editor-yjs @dineug/erd-editor yjs y-protocols
npm install y-websocket # or another provider

الحزم @dineug/erd-editor وyjs وy-protocols تبعيات نظير، لذا تحتوي الصفحة على نسخة واحدة من كل منها: @dineug/erd-editor الإصدار 5.1.0 أو أحدث ضمن 5.x، وyjs الإصدار 13.6 أو أحدث ضمن 13.x، وy-protocols الإصدار 1.0 أو أحدث ضمن 1.x.
تأخذ الحزمة أنواعها فقط وتعمل على Y.Doc وAwareness اللذين تمررهما، لذا لا تحمّل شيفرة Yjs أو y-protocols أو المحرر من عندها.
تُفسد نسختان من Yjs في صفحة واحدة فحوص المنشئ لدى إحداهما الأخرى، وتسجّل الثانية Yjs was already imported. عندما تحل شيفرتك ومزوّد ما yjs من موضعين مختلفين، أزل التكرار في أداة التجميع (Vite: resolve.dedupe: ['yjs']).

الحزمة ESM فقط ("type": "module")، ولها نقطة دخول واحدة هي جذر الحزمة، وهي معلَّمة بأنها خالية من الآثار الجانبية، لذا فإن الحزمة المجمَّعة التي تستورد bindErdEditor فقط لا تتضمن الضاغط.
تحتاج bindErdEditor إلى DOM لأنها تربط عنصر <erd-editor> معرَّفًا. أما bindPeerStore فتعمل أيضًا في Node العادي، على Node.js 22.12 أو أحدث، كما تعمل عملية الضغط المساعدة.

الاستخدام​

function bindErdEditor(
editor: ErdEditorElement,
doc: Y.Doc,
options?: BindOptions
): ErdYjsBinding;
import '@dineug/erd-editor/erd-editor.js';

import { bindErdEditor } from '@dineug/erd-editor-yjs';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://yjs.example.com', 'my-diagram', doc);
const editor = document.createElement('erd-editor');
document.body.appendChild(editor);

provider.on('sync', function onSync(synced) {
if (!synced) return;
provider.off('sync', onSync);

const binding = bindErdEditor(editor, doc, {
awareness: provider.awareness,
getNickname: () => 'Ada',
});
binding.on('error', error => console.error(error));
binding.on('rejoin', reason => console.info('reopened on the room', reason));
binding.seed(); // false, and nothing written, once the room holds a diagram
});

القيمتان wss://yjs.example.com وmy-diagram تمثلان خادمك وغرفتك.
اربط وهيّئ فقط بعد أن يكتمل تزامن المزوّد، كما يخبرك حدث sync في y-websocket بالقيمة true. قد تستبدل تهيئة تتم قبل ذلك مخطط الغرفة، راجع تهيئة الغرفة.

عندما يحتوي Y.Doc على لقطة غرفة وقت الربط، يفتحها الربط في العنصر فورًا: فيحمّل اللقطة مع مسجّلاتها ورقم نسختها، كما تفعل reseed، ثم يطبّق كل تعديل سُجّل منذ ذلك الحين.
ذلك الفتح يمسح ما تمسحه إعادة الانضمام ويغلق ما تغلقه، راجع التعديلات دون اتصال وإعادة الانضمام، ويُفتح العنصر حيث تضعه اللقطة. ولا يُطلَق rejoin عند هذا الفتح الأول.
وعندما لا يحتوي Y.Doc على لقطة، كما في غرفة فارغة أو قبل اكتمال تزامن المزوّد، يحتفظ العنصر بمستنده، ولا يكتب الربط شيئًا في الغرفة إلى أن تظهر لقطة، سواء كانت تهيئته هو أو تهيئة عميل آخر.

تأخذ bindErdEditor مخزنًا مشتركًا واحدًا من العنصر باستخدام getSharedStore، وتتيح destroy التخلي عنه.
يجب أن يكون العنصر <erd-editor> بالفعل، أي أن تكون نقطة دخوله مستوردة أو أن تكون defineErdEditor() قد استُدعيت، راجع تسجيل العناصر. إلى أن يُعرَّف الوسم، يكون <erd-editor> مجرد HTMLElement عادي، يرقّيه المتصفح عندما يُعرَّف الوسم ويكون العنصر في المستند. وبالنسبة إلى عنصر لم تتم ترقيته بعد، ترمي bindErdEditor الخطأ TypeError بدلًا من إطلاق error.

تُطبَّق تعديلات الغرفة على العنصر وتُطلِق change كما تفعل تعديلات المتعاون، ومنها التعديلات المعاد تشغيلها بعد فتح لقطة.
فتح لقطة، وقت الربط أو عند إعادة الانضمام، لا ينشئ إدخالًا في سجل التراجع ولا يُطلِق change ولا يكتب شيئًا في الغرفة، عدا اشتقاق الاسم المنطقي الذي تصفه التعديلات دون اتصال وإعادة الانضمام.
لتحميل مستند آخر في غرفة مهيَّأة، أجرِ تغييرًا يطبّقه كل عميل: عيّن value أو استدعِ clear أو استورد مستندًا.
لا تُكتب setInitialValue في الغرفة أبدًا لأن المخزن المشترك لا يرسلها، فهي تغيّر ذلك العنصر وحده بينما يحتفظ كل عميل آخر بمخطط الغرفة. هيّئ غرفة فارغة باستخدام seed بدلًا من ذلك.

يحتوي Y.Doc الواحد على مخطط واحد. يحتفظ الربط به تحت أسماء erd: في Y.Doc ويعيّن الحقل erd في حالة Awareness المحلية، ويترك كل اسم وحقل آخر كما هو.
امنح كل عنصر مربوط Y.Doc وAwareness خاصين به، كما لكل عميل في الغرفة: فربطان يتشاركان Awareness واحدًا يكتب كل منهما فوق حقل erd للآخر.

تهيئة الغرفة​

لا تحتوي الغرفة على أي مخطط إلى أن يُهيّئها عميل بالدالة seed.
ما يحتويه المحرر في الأثناء يدخل في تهيئته هو، ويفسح المجال لتهيئة عميل آخر إذا وصلت تلك أولًا: فيعيد الربط الفتح على تلك التهيئة بـ rejoin('snapshot')، ويضيع المستند الذي كان العنصر يحتويه مع ما عُدّل فيه منذ الربط.

عندما يهيّئ عميلان في اللحظة نفسها، تفوز تهيئة واحدة على كل عميل.
يعيد المهيّئ الآخر الفتح عليها بـ rejoin('seedLost') ويفقد ما عدّله منذ تهيئته، وكذلك العميل الذي كان قد انضم إلى التهيئة الخاسرة، بـ rejoin('snapshot'). أما تعديلاتهم في التهيئة الخاسرة فلا تصل إلى أحد.
تُعدّ التهيئة التي تتم قبل اكتمال تزامن المزوّد كأنها تمت في اللحظة نفسها مع التهيئة التي جاء منها مخطط الغرفة، لذا قد تفوز عليها وتستبدل مخطط الغرفة.

BindOptions​

import type { Awareness } from 'y-protocols/awareness';

type BindOptions = {
awareness?: Awareness;
getNickname?: () => string;
};

awareness​

كائن Awareness الذي ينقل المؤشرات والتركيز والتحديدات ومربعات السحب، مثل الكائن الخاص بالمزوّد.
وبه يُفعّل الربط متتبعي الماوس والتركيز في العنصر ويرسم حضور العملاء الآخرين على العنصر، راجع الحضور.
أما بدونه فيتزامن المخطط وحده: فلا يرسل العنصر مؤشرًا ولا تركيزًا ولا تحديدًا، ولا يرسم حضور أحد غيره.

bindErdEditor(editor, doc, { awareness: provider.awareness });

getNickname​

الاسم الذي يراه الآخرون بجانب مؤشر هذا المحرر.
يقرؤه المخزن المشترك للعنصر في كل مرة يرسل فيها، كما هي الحال مع getSharedStore، ويرى المستخدمون الآخرون user عندما يكون مفقودًا أو فارغًا.

bindErdEditor(editor, doc, {
awareness: provider.awareness,
getNickname: () => 'nickname...',
});

ErdYjsBinding​

type ErdYjsBinding = {
seed: (value?: string) => boolean;
on: <K extends keyof BindingEvents>(
event: K,
listener: BindingEvents[K]
) => () => void;
destroy: () => void;
};

type BindingEvents = {
rejoin: (reason: RejoinReason) => void;
error: (error: Error) => void;
};

type RejoinReason = 'snapshot' | 'seedLost';

تعيد on(event, listener) دالة تزيل المستمع.

seed​

تكتب أول لقطة للغرفة من مستند المحرر وتعيد true.
عند تمرير value، وهو نص مستند بأي صيغة تقرؤها setInitialValue، تحمّله في المحرر أولًا.
ثم تتخلى، دون تسجيل، عن عمليات السحب وتغيير الحجم وتغييرات اللون التي لا يزال المحرر يحتجزها، فلا يصل أي منها إلى الغرفة بعد اللقطة. وبدون value تُظهرها اللقطة أصلًا لأنها تأخذ مستند المحرر؛ ومع value كانت تخص المستند الذي استبدلته value.
ثم تكتب اللقطة التي يصفها ما تحتويه الغرفة.
وما إن تحتوي الغرفة على لقطة حتى تعيد false ولا تكتب شيئًا ولا تحمّل value، لذا يمكن لكل عميل استدعاؤها بعد الربط. وتعيد false أيضًا بعد تدمير الربط أو توقفه، وعلى ربط رُفض وقت الربط.

binding.seed(); // the document the editor holds
// or
binding.seed(await (await fetch('/diagrams/shop.erd.json')).text());

on@rejoin​

أعاد الربط فتح المحرر على لقطة الغرفة، كما تفعل reseed؛ وتبيّن التعديلات دون اتصال وإعادة الانضمام ما يحتفظ به وما يفقده.
ويبيّن reason السبب:

  • 'snapshot': تحتوي اللقطة على تعديلات لم يطبّقها الربط، لأنه كان غائبًا بينما ضمّها ضاغط، أو لأن ضاغطًا أعاد في ضمّة خاصة به ما كان قد ضمّه في لقطة حلّت محلها لقطة ضاغط آخر (راجع ضاغط واحد لكل غرفة). وهو أيضًا السبب عندما تكون اللقطة أول ما يصل إلى ربط غرفة فارغة، وعندما تكون لقطة تهيئة أخرى وكان هذا الربط قد انضم إلى تهيئة لم يكتبها.
  • 'seedLost': فازت تهيئة عميل آخر على التهيئة التي كتبها هذا الربط.
binding.on('rejoin', reason => {
console.info('reopened on the room', reason);
});

on@error​

رفض الربط أن يربط، أو توقف، أو رفض خيارًا. تبدأ كل رسالة بـ erd-editor-yjs: .

وقت الربط لا يربط شيئًا عندما:

  • يكون المحرر أو مخزن النظير مربوطًا بالفعل بهذا Y.Doc؛
  • يكون @dineug/erd-editor فيه مبنيًا قبل الإصدار 5.1.0؛ ويدمّر الربط المخزن المشترك الذي أخذه من عنصر؛
  • يكون مخزن النظير قد دُمّر قبل ربطه؛
  • تحتوي الغرفة على لقطة ببنية لا يقرؤها هذا الإصدار (راجع ما تحتويه الغرفة).

يطلق مثل هذا الربط error في مهمة دقيقة (microtask)، لذا يسمعه مستمع أُضيف مباشرة بعد الاستدعاء، ولا يُطلَق عليه rejoin أبدًا.

وفي وقت لاحق يتوقف الربط عندما:

  • تتحول لقطة الغرفة إلى بنية لا يقرؤها هذا الإصدار. فيتوقف كل ربط من هذا الإصدار، ويحتفظ كل محرر بما طبّقه، ولا يُكتب شيء مما يُعدَّل بعد ذلك؛
  • يُدمَّر مخزن النظير الخاص به من تحته، ويُكتشف ذلك في المرة التالية التي تتغير فيها الغرفة أو يُستدعى فيها seed() أو compact().

يتخلى الربط المتوقف عن المخزن المشترك الذي أخذه، وعن مخزنه وY.Doc بوصفهما زوجًا، كما تفعل destroy، ويحتفظ بمستمعيه.

وبالنسبة إلى bindPeerStore وحدها، تطلق هذه الحالات error ويواصل الربط عمله:

  • compactAfter ليس عددًا صحيحًا موجبًا: لا يضم الربط إلا عند الطلب؛
  • electionGraceMs أو retainMs ليس رقمًا من 0 إلى 2,147,483,647 مللي ثانية: تحل قيمته الافتراضية محله، لأن المؤقت الذي يتجاوز هذا النطاق يُطلَق فورًا؛
  • synced يُرفض: لا يضم الربط أبدًا، ويترك الانتخاب للضواغط الأخرى (راجع ضاغط واحد لكل غرفة).
binding.on('error', error => {
console.error(error.message);
});

destroy​

ترسل إلى الغرفة عمليات السحب وتغيير الحجم وتغييرات اللون التي لا يزال المحرر يحتجزها، وتعيّن حقل Awareness erd الخاص بالربط إلى null ليُسقط الآخرون مؤشره وتركيزه وتحديده فورًا، وتتوقف عن مراقبة Y.Doc وAwareness.
وفي حالة العنصر تدمّر أيضًا المخزن المشترك الذي أخذته، وتترك العنصر ومستنده كما هما.
الاستدعاء الثاني لا يفعل شيئًا. وبعده لا يصل ما يعدّله المحرر إلى أحد.

يبقى مخزن النظير وY.Doc والمزوّد ملكًا لك، لتدمّرها بعد الربط: فمخزن النظير الذي يُدمَّر أولًا لا يرسل أبدًا عمليات السحب التي كان لا يزال يحتجزها.
وتدمّر destroy الخاصة بالعنصر كل مخزن مشترك سلّمه، ومنها مخزن الربط، وتُسقط ما يحتجزونه، لذا دمّر الربط أولًا.
دمّر الربط قبل ربط المحرر أو مخزن النظير نفسه بـ Y.Doc نفسه مرة أخرى؛ أما الربط الذي توقف بسبب error فقد تخلى عن ذلك بالفعل.

binding.destroy();
editor.destroy();

الحضور​

مع awareness، ينتقل مؤشر المحرر والجدول أو الخلية التي عليها التركيز وتحديده ومربع سحبه في الحقل erd من حالة Awareness المحلية، لا في Y.Doc أبدًا، لذا لا تدخل أبدًا في سجل الغرفة أو لقطتها أو Y.Doc المخزَّن تخزينًا دائمًا.
يرسمها الآخرون كما يرسمون حضور المتعاون، راجع focusTracker، ويرسم العميل المنضم الحضور الذي تُظهره الغرفة بالفعل.
يعيد العنصر إرسال تركيزه وتحديده كل 30 ثانية ومربع سحبه كل ثانية. وكل إعادة إرسال من هذه تصل إلى الآخرين حتى عندما تكرر آخر ما أُرسل، لذا لا ينقطع أبدًا ما لا يزال المستخدم ممسكًا به.

يُنسى العميل الذي يغادر فورًا.
عندما يُسقط Awareness حالته، أو تصبح قيمة حقله erd هي null، كما تعيّنه destroy، أو يشير إلى محرر آخر، يزيل الربط مؤشر ذلك المحرر وتركيزه وتحديده ومربع سحبه من العنصر بالدالة forgetPeer بدلًا من انتظار انتهاء صلاحيتها.
ويُبقيها ما دام عميل آخر لا يزال يُظهر المحرر نفسه، كما في عنصر رُبط من جديد بـ Y.Doc آخر من الغرفة.

التعديلات دون اتصال وإعادة الانضمام​

يواصل العميل المنقطع عن المزوّد التحرير. تنتظر تعديلاته في Y.Doc المحلي لديه، لا في المخزن المشترك، وتصل إلى الآخرين عندما يعيد المزوّد الاتصال ويكتمل التزامن.

عندما يكون ضاغط قد ضمّ تعديلات لم يطبّقها العميل أثناء غيابه، تحتويها اللقطة، فعند إعادة الاتصال يعيد العميل الفتح على اللقطة بـ rejoin('snapshot')، بعد إرسال عمليات السحب وتغيير الحجم وتغييرات اللون التي كان محرره لا يزال يحتجزها. أما ضمّ ما كان العميل قد طبّقه فقط فلا يعيد فتح شيء.
وتعيد إعادة الانضمام على لقطة التهيئة التي انضم إليها الربط تشغيل كل تعديل لا تحتويه اللقطة، ومنها تعديلات هذا العميل نفسه التي أجراها أثناء غيابه، فلا يضيع منها شيء. أما إعادة الانضمام على تهيئة أخرى فلا تُبقي شيئًا، كما تصف تهيئة الغرفة.

تمسح كل إعادة انضمام سجل التراجع والتحديد والتركيز، وتغلق عرض التدفق المفتوح ورسم علاقة ومحرر ملاحظة أو اسم مجموعة جداول أو خلية، لأنها قد تسمّي ما لم تعد اللقطة تحتويه.
ويُسقط اسم مجموعة جداول كُتب ولم يُدخَل بعد. وتشتق خلية الاسم المنطقي التي تُغلق الاسم المادي من اسم منطقي كتبه قارئها، حيث لا يزال المستند المعاد فتحه يحتويه، كما يفعل أي إغلاق لتلك الخلية. وذلك الاشتقاق تعديل عادي: تسجله الغرفة، وينشئ إدخالًا في سجل التراجع، ويُطلِق change، راجع reseed.
ويُبقي الربط الذي كان قد انضم إلى الغرفة ما يراه قارئه: موضع التمرير ومستوى التكبير وعلامة التبويب، والإعدادات التي غيّرها قارئ للقراءة فقط.
ويُبقي أيضًا عرض الأسماء وإعدادات التعليمات البرمجية (لغة منشئ التعليمات البرمجية وأنماط التسمية ونوع علامات الاقتباس) التي أظهرها القارئ بنفسه، ما دام قفل كل منها مفعّلًا قبل إعادة الانضمام وما زال مفعّلًا بعدها عند القيمة المقفلة نفسها.
ولا يصل شيء مما يُبقيه إلى value، الذي يعرض إعدادات المستند المعاد فتحه، راجع reseed مع keepScreen.
أما موضع التمرير ومستوى التكبير لعنصر لم يُعرض منذ آخر تحميل له، والشاشة كلها لربط لم ينضم بعد، فتُفتح حيث تضعها اللقطة.
ولا شاشة لمخزن النظير، لذا لا تُبقي إعادة انضمامه شيئًا منها.

المحررات للقراءة فقط​

العنصر للقراءة فقط المربوط بغرفة لا يكتب شيئًا في سجل الغرفة: فمخزنه المشترك لا يرسل أي تغيير، بل متتبعاته ومصافحة المسجّلات فقط، ولا تسجّل الغرفة أيًّا منهما.
وهو يطبّق مع ذلك كل تعديل تنقله الغرفة، ومع awareness يواصل مؤشر قارئه وتركيزه وتحديده ومربع سحبه الوصول إلى الآخرين.
وتبقى الإعدادات التي يغيّرها قارئه على شاشته، كما في أي محرر للقراءة فقط.
seed غير مرفوضة: فالعنصر للقراءة فقط يهيّئ غرفة فارغة بالمستند الذي يحتويه، أو بـ value عند تمريرها.

ما تحتويه الغرفة​

يحتفظ الربط بالمخطط تحت اسمين في Y.Doc: erd:snapshot، وهي خريطة تحتوي على لقطة من المستند، وerd:log، وهي مصفوفة من دفعات إجراءات المحرر منذ تلك اللقطة. يخزّن مزوّد التخزين الدائم كليهما مع بقية Y.Doc.

تحتوي اللقطة على runtimeValue الخاصة بالمستند، ومنها الكيانات المحذوفة، ومسجّلات التعاون ورقم النسخة فيه، ومدى ضمّ تعديلات كل كاتب فيها. تكتبها التهيئة، وكذلك كل ضمّة لضاغط.
كل إدخال في سجل الدفعات دفعة واحدة كما أرسلها المحرر، بما فيها بياناتها الوصفية التي تحمل معرّف محرر المرسل واسمه المستعار.
لا تُسجَّل أبدًا عمليات التمرير والتكبير والتبديل بين علامات التبويب والحضور ومصافحة المسجّلات. أما كل تغيير آخر يرسله المحرر فيُسجَّل، ومنه تبديل عرض الأسماء الذي يتم تحت قفله، الذي يُسقطه كل عميل آخر.
أما لغة منشئ التعليمات البرمجية أو نمط التسمية أو نوع علامات الاقتباس إذا تغيّر تحت قفله فلا يُرسل أبدًا، فيبقى على شاشة ذلك المحرر ولا يصل إلى الغرفة.

يفتح Y.Doc الذي يعيده مزوّد التخزين الدائم المخطط الذي يحتويه، كما يفتح الربط أي Y.Doc يحتوي على لقطة، راجع الاستخدام.

اللقطة ليست ملفًا: فقيمة وقت التشغيل فيها تحمل كيانات محذوفة لا يجوز أن يحتويها الملف.
لكتابة مخطط الغرفة في وسيط تخزين، احفظ value الخاصة بعنصر مربوط كلما أُطلق الحدث change.
واحفظها أيضًا مرة بعد الربط، وبعد seed مُرِّرت إليها value، وفي مستمع rejoin، لأن أيًّا من عمليات التحميل هذه لا يُطلِق change.
العنصر للقراءة فقط لا يُطلِق change أبدًا، لذا احفظ من عنصر ليس للقراءة فقط، أو اقرأ value من مخزن نظير مربوط، يطبّق تعديلات الغرفة فور وصولها.
لا يمكن ربط replicationStore لأنه يفتقر إلى الدوال التي يحتاجها الربط، وإذا غُذّي من المخزن المشترك لعنصر مربوط فاتته تعديلات الغرفة، راجع النسخ المتماثل لمحرر قيد التشغيل.

البنية خاصة وقد تتغير مع ERD_YJS_FORMAT.
اقرأ الغرفة عبر الربط فقط، واترك أسماء erd: وحقل Awareness erd له.

ضغط الغرفة​

ينمو سجل الغرفة مع كل تعديل، ويعيد كل عميل ينضم تشغيله كله، إلى أن يضمّه شيء ما.
تربط bindPeerStore مخزن نظير بدون واجهة من @dineug/erd-editor/peer.js وتعيد ErdYjsPeerBinding: وهو seed وon وdestroy نفسها، مع compact.
يضمّ مخزن النظير المربوط في عملية Node بجانب الخادم، وهي العملية المساعدة، سجل الغرفة في لقطة من مخططه هو.
ولا يضمّ ربط العنصر أبدًا، ومخزن النظير المربوط بلا compactAfter وبلا استدعاء compact() هو نظير عادي بدون واجهة.

function bindPeerStore(
peer: PeerStore,
doc: Y.Doc,
options?: PeerBindOptions
): ErdYjsPeerBinding;

type ErdYjsPeerBinding = ErdYjsBinding & {
compact: () => CompactResult;
};
npm install @dineug/erd-editor-yjs @dineug/erd-editor yjs y-protocols y-websocket ws
import { createPeerStore } from '@dineug/erd-editor/peer.js';
import { bindPeerStore } from '@dineug/erd-editor-yjs';
import WebSocket from 'ws';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://yjs.example.com', 'my-diagram', doc, {
WebSocketPolyfill: WebSocket,
});
const synced = new Promise(resolve => {
provider.on('sync', function onSync(isSynced) {
if (!isSynced) return;
provider.off('sync', onSync);
resolve();
});
});

const peer = createPeerStore({ nickname: 'compactor', presence: false });
const binding = bindPeerStore(peer, doc, {
awareness: provider.awareness,
compactAfter: 1000,
synced,
});
binding.on('error', error => console.error(error));

process.on('SIGTERM', () => {
binding.destroy(); // first, so the peer store's held drags still reach the room
peer.destroy();
provider.destroy();
doc.destroy(); // ends the provider's Awareness timer, so the process can exit
});

يبقى مخزن النظير ملكًا لك: فالربط لا يدمّره أبدًا. اسمه المستعار هو الاسم الذي مُرِّر إلى createPeerStore، وpresence: false يمنعه من إرسال تركيز خاص به.

تكتب الضمّة اللقطة من قيمة وقت التشغيل ومسجّلات مخزن النظير نفسه ورقم نسخته، وتحذف إدخالات سجل الدفعات التي تغطيها وكل إدخال من تهيئة أخرى، كل ذلك في معاملة واحدة.
العملاء الذين طبّقوا كل شيء يرون الإدخالات تختفي؛ أما العميل الذي لم يطبّقها فيعيد الفتح على اللقطة (راجع التعديلات دون اتصال وإعادة الانضمام).
تعيد كل ضمّة كتابة المخطط كله في اللقطة، لذا ترسل كل ضمّة إلى كل عميل تحديثًا بحجم JSON وقت التشغيل للمخطط والمسجّلات معًا تقريبًا، مهما قلّ عدد الإدخالات التي تضمّها. اضبط compactAfter على قيمة عالية بما يكفي لتفوق الإدخالات التي تضمّها الضمّة نسخةً واحدة من المخطط.

PeerBindOptions​

type PeerBindOptions = CompactOptions & {
awareness?: Awareness;
};

type CompactOptions = {
compactAfter?: number;
synced?: Promise<void>;
electionGraceMs?: number;
retainMs?: number;
};
الخيارالافتراضيالوصف
awarenessلا يوجديُظهر مخزن النظير في Awareness الخاص بالغرفة، كما يفعل awareness مع عنصر. يُعلَّم فيه الضاغط الذي يضمّ من تلقاء نفسه، فتنتخب الضواغط التي ترى بعضها واحدًا منها ليضمّ، ولا يضمّ الضاغط الذي لا يرى أي عميل آخر شيئًا من تلقاء نفسه، راجع ضاغط واحد لكل غرفة. وبدونه يضمّ الضاغط من تلقاء نفسه بلا انتخاب وبلا مهلة.
compactAfterلا يوجدالضمّ من تلقاء نفسه ما إن يحتوي سجل الدفعات على هذا العدد من الإدخالات التي ستضمّها الضمّة. وبدونه، أو بدون synced، لا تضمّ إلا compact()، والضمّة التي تعيد ما ضُمّ (راجع ضاغط واحد لكل غرفة).
syncedلا يوجدوعد يُحَلّ ما إن يكتمل تزامن المزوّد. لا تتم أي ضمّة قبله، ومنها compact(). وبدونه لا يضمّ شيء من تلقاء نفسه وتضمّ compact() فورًا.
electionGraceMs3000مع awareness، المدة التي يراقب فيها الضاغط الغرفة قبل أن يضمّ من تلقاء نفسه، بعد synced ومجددًا كلما ظهر عملاء آخرون بعد أن لم يظهر أحد.
retainMs600000 (عشر دقائق)المدة التي تبقى فيها إدخالات الضمّة محتجزة لإعادتها (راجع ضاغط واحد لكل غرفة)، تُحسب من أول تحديث ترسله الغرفة بعد الضمّة.

أي خيار غير صالح، أو synced يُرفض، يطلق error.

compact​

type CompactResult =
| { compacted: true; generation: number }
| {
compacted: false;
reason: 'not-synced' | 'no-snapshot' | 'destroyed';
};

تضمّ فورًا، بلا انتخاب ولا مهلة ولا حد أدنى.
تأخذ أولًا لقطة الغرفة الحالية، ومنها إعادة الانضمام، وترسل ما لا يزال مخزن النظير الخاص بها يحتجزه.
وتجيب بـ { compacted: true, generation }، حيث يعدّ generation ضمّات اللقطة: 0 للتهيئة، وواحدًا إضافيًا مع كل ضمّة.
وفيما عدا ذلك تجيب بـ { compacted: false, reason }:

  • 'destroyed' بعد تدمير الربط أو توقفه، وعلى ربط رُفض وقت الربط، حتى قبل أن يُحَلّ synced؛
  • 'not-synced' قبل أن يُحَلّ synced؛
  • 'no-snapshot' في غرفة لم يهيّئها أي عميل.
const result = binding.compact();
if (!result.compacted) console.warn(result.reason);

ضاغط واحد لكل غرفة​

شغّل ضاغطًا واحدًا بالضبط لكل غرفة، ولا تبدأ ضاغطًا جديدًا إلا بعد أن يتوقف القديم. لا شيء يفرض ذلك.
بين الضواغط التي ترى بعضها في Awareness، لا يضمّ من تلقاء نفسه إلا الضاغط ذو أصغر معرّف عميل في Yjs، وعندما يغادر، أو يُرفض synced الخاص به، يتولى التالي الأمر فورًا.
والضاغط الذي لا يُظهر Awareness الخاص به أي عميل آخر، كما عندما يُغلق مقبسه فيُسقطهم جميعًا، لا يضمّ شيئًا من تلقاء نفسه إلى أن يظهر آخرون من جديد ويكون قد راقب الغرفة مدة electionGraceMs.

يمكن لضاغطين منقطعين أحدهما عن الآخر، أو لإعادة تشغيل لا يزال سابقها يعمل، أن يضمّ كل منهما مع ذلك.
عندها تحتفظ الغرفة بإحدى اللقطتين، ويعيد الضاغط الذي خسرت لقطته ما ضمّه مما لا تحتويه لقطة الفائز، ما دام يحتجزه: أي retainMs، وأحدث 1,000 إدخال منه على الأكثر، مهما كبر كل إدخال.
لذا لا يضيع شيء عندما يكون الضاغط الذي خسرت لقطته هو الذي أعاد الاتصال، ما دام لم يضمّ أكثر من 1,000 إدخال أثناء الانفصال.
وعندما يكون هو الذي بقي متصلًا وانتهت مهلة احتجازه، تفتقر لقطة الغرفة وسجلها إلى ما ضمّه إلى أن يضمّ من جديد. والعميل الذي فاتته تلك التعديلات، أو الذي ينضم في الأثناء، يفتقر إليها، ويحتجز التعديلات اللاحقة لكل كاتب ضمّ الضاغط تعديلاته. وتعيدها كلها ضمّة الضاغط التالية، ما لم تحتوِ اللقطة الفائزة على تعديلات كان يفتقر إليها، فهذا يعيد فتحه على تلك اللقطة ويُضيع ما ضمّه نهائيًا.

يعيد الضاغط ما ضمّه في ضمّة خاصة به، تتم بعد أن يلحق بلقطة الفائز، بلا انتخاب ولا مهلة ولا حد أدنى، ويعيد كل عميل الفتح على تلك الضمّة بـ rejoin('snapshot').
والتعديلات التي يعيدها لا تصل إلى أي عميل بغير تلك الضمّة، لذا ينتهي العميل المتصل طوال الوقت إلى ما ينتهي إليه الآخرون والعميل الذي ينضم لاحقًا، حتى عندما يبني تعديل كاتب آخر لاحق على أحدها، مثل إعادة تسمية جدول أضافه عميل آخر.
تكلّف كل إعادة تُجرى كل عميل إعادة الانضمام تلك، مع ما تفقده إعادة الانضمام. وعندما يعيد ضاغطان في اللحظة نفسها، ينتهي كل عميل مع ذلك إلى النتيجة نفسها، وإن كان قد يبقى تعديل مطبّقًا قبل التعديل الذي يبني عليه.
والربط الذي يُدمَّر أو يتوقف قبل تشغيل تلك الضمّة، كما عندما يدمّره مستمع rejoin، يعيد التعديلات بمفردها بدلًا من ذلك. لا يضيع منها شيء، لكن عندئذٍ قد يطبّق العميل المتصل طوال الوقت أحدها بعد تعديل كاتب آخر يبني عليه، وينتهي إلى ما يختلف عن العميل الذي ينضم لاحقًا.

الحدود​

  • يبقى إطار العرض على كل شاشة. عمليات التمرير والتكبير والتبديل بين علامات التبويب لا تُسجَّل أبدًا، مع أن الجلسة الموصولة عبر getSharedStore ترسلها (وهي لا تحرّك عرض أي مستخدم آخر ولا علامة تبويبه هناك أيضًا). يحتفظ كل قارئ بإطاره، ولا يُظهره الربط إلا في حالة Awareness الخاصة به، التي لا يقرؤها شيء حتى الآن. ومع قفل Viewport أو Canvas Type معطّلًا، تحفظ value لكل عميل قيمته هو؛ وتحت القفلين، وهما مفعّلان في المستند الجديد، يحفظ كل عميل القيمة المقفلة.
  • تنمو اللقطة بإدخال واحد لكل ربط. تحتفظ، لكل ربط كتب في الغرفة يومًا، بمدى ضمّ تعديلاته، ولا تُسقط أحدها أبدًا. يُحسب الربط المُنشأ من جديد، أو الذي انتقل إلى تهيئة أخرى، من جديد، وكذلك كل ضمّة تعيد ما ضُمّ. الإدخال معرّف من 32 حرفًا ورقم، لذا تضيف عشرة آلاف جلسة كتابة بضع مئات من الكيلوبايت إلى كل لقطة وكل ضمّة.
  • لا ينقل المحور والغرفة شيئًا بينهما. العنصر المربوط بغرفة وبـ محور وكيل برمجة، أو بأي وسيلة نقل أخرى عبر getSharedStore()، يطبّق تعديلات كليهما، لكن لا يسمع أي منهما تعديلات الآخر، لأن المخزن المشترك لا يرسل أبدًا ما تلقّاه.
  • قد تُدرج الإضافات التي تتم في اللحظة نفسها بترتيب مختلف. قد تقف الجداول أو الملاحظات التي يضيفها عميلان في اللحظة نفسها بترتيب مختلف في مخطط كل عميل، كما في جلسة موصولة عبر getSharedStore، راجع التعديلات المتزامنة.
  • قد تنتهي الألوان وبعض التغييرات الأخرى التي تتم في اللحظة نفسها إلى نتائج مختلفة. يُسجَّل تغيير اللون بلا رقم نسخة، ويختمه كل عميل برقمه هو عند تطبيقه. لذا قد يترك تغييرا لون لجدول أو ملاحظة أو مجموعة جداول واحدة يتمّان في اللحظة نفسها العملاء، والعميل الذي ينضم لاحقًا ويعيد تشغيل سجل الدفعات، على ألوان مختلفة. وكذلك التغييرات الأخرى التي يطبّقها كل عميل بالترتيب الذي يسمعها به، مثل نقلي عمود داخل جدول أو فهرس واحد، أو تغييري قاعدة البيانات، راجع التعديلات المتزامنة.
  • يعلق المؤشر المغادر عند الخادم. يمسح الربط مؤشر النظير بمجرد أن يُسقطه Awareness. والخادم الذي يزيل، عند إغلاق مقبس، حالات Awareness التي أضافها ذلك المقبس أولًا فقط، يُبقي عميلًا كان قد أعاد الاتصال على شاشات الآخرين بعد مغادرته، إلى أن تنتهي صلاحيته في Awareness بعد 30 ثانية. أما الخادم الذي يزيل كل حالة كتبها المقبس آخر مرة، تحديثًا كانت أو إضافة، فيمسحه فورًا.

الصادرات​

الصادرالوصف
bindErdEditor(editor, doc, options?)يربط عنصر <erd-editor>، راجع الاستخدام. يعيد ErdYjsBinding.
bindPeerStore(peer, doc, options?)يربط مخزن نظير من @dineug/erd-editor/peer.js، راجع ضغط الغرفة. يعيد الشيء نفسه مع compact (ErdYjsPeerBinding).
ERD_YJS_FORMATبنية Y.Doc التي يكتبها هذا الإصدار، 1؛ ويرفض الربط غرفة كُتبت ببنية أخرى.

الأنواع: BindOptions وPeerBindOptions وCompactOptions وCompactResult وBindingEvents وRejoinReason وErdYjsBinding وErdYjsPeerBinding.